From 069fc674dc0911457b6f252142eb6d78cd6a90ed Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 14 Oct 2026 00:16:34 -0400 Subject: [PATCH] Cthulhu is no longer a fork. Now it is a screen reader written in Golang. --- .gitignore | 13 + AUTHORS | 5 + LICENSE | 232 ++++ assets/sounds/default/autocomplete.wav | Bin 0 -> 9644 bytes assets/sounds/default/browse_mode.wav | Bin 0 -> 264644 bytes assets/sounds/default/button.wav | Bin 0 -> 38444 bytes assets/sounds/default/checkbox_checked.wav | Bin 0 -> 33908 bytes assets/sounds/default/checkbox_mixed.wav | Bin 0 -> 58700 bytes assets/sounds/default/checkbox_unchecked.wav | Bin 0 -> 33908 bytes assets/sounds/default/combobox.wav | Bin 0 -> 48044 bytes assets/sounds/default/focus_mode.wav | Bin 0 -> 134444 bytes assets/sounds/default/link.wav | Bin 0 -> 19244 bytes assets/sounds/default/link_visited.wav | Bin 0 -> 19244 bytes assets/sounds/default/radiobutton_checked.wav | Bin 0 -> 96044 bytes .../sounds/default/radiobutton_unchecked.wav | Bin 0 -> 96044 bytes assets/sounds/default/start.wav | Bin 0 -> 499244 bytes assets/sounds/default/stop.wav | Bin 0 -> 384044 bytes assets/sounds/default/switch_off.wav | Bin 0 -> 38444 bytes assets/sounds/default/switch_on.wav | Bin 0 -> 38444 bytes .../sounds/default/togglebutton_checked.wav | Bin 0 -> 40364 bytes .../sounds/default/togglebutton_unchecked.wav | Bin 0 -> 40364 bytes assets/sounds/embed.go | 8 + cmd/cthulhu-braille/main.go | 33 + cmd/cthulhu/main.go | 227 ++++ cmd/cthulhu/main_test.go | 227 ++++ cmd/cthulhu/replace.go | 149 +++ cmd/cthulhu/replace_test.go | 95 ++ cmd/parityledger/main.go | 65 + cmd/parityledger/main_test.go | 47 + cmd/tentacle-host/main.go | 84 ++ .../Arch-Linux/cthulhu-git/.SRCINFO | 42 + .../Arch-Linux/cthulhu-git/PKGBUILD | 108 +- .../cthulhu-wine-access-git/PKGBUILD | 57 - .../Arch-Linux/cthulhu-wine-access/PKGBUILD | 40 - distro-packages/Arch-Linux/cthulhu/PKGBUILD | 93 -- distro-packages/Slackware/cthulhu.SlackBuild | 90 -- distro-packages/Slackware/doinst.sh | 11 - distro-packages/Slackware/slack-desc | 19 - distro-packages/Slint/README | 37 - distro-packages/Slint/cthulhu-info | 10 - distro-packages/Slint/cthulhu.SlackBuild | 90 -- distro-packages/Slint/doinst.sh | 11 - distro-packages/Slint/slack-desc | 19 - go.mod | 9 + go.sum | 6 + internal/app/app.go | 371 ++++++ internal/app/app_test.go | 117 ++ internal/app/application_focus_test.go | 61 + internal/app/autocomplete.go | 94 ++ internal/app/braille_context.go | 76 ++ internal/app/braille_context_test.go | 99 ++ internal/app/braille_frames.go | 299 +++++ internal/app/braille_helper_test.go | 104 ++ internal/app/braille_presentation.go | 431 ++++++ internal/app/braille_presentation_test.go | 397 ++++++ internal/app/braille_routing.go | 79 ++ internal/app/braille_session.go | 161 +++ internal/app/braille_text_attributes.go | 64 + internal/app/braille_text_attributes_test.go | 114 ++ internal/app/braille_worker.go | 125 ++ internal/app/braille_worker_test.go | 173 +++ internal/app/browser_dynamic_test.go | 152 +++ internal/app/browser_events_test.go | 1049 +++++++++++++++ internal/app/browser_mode.go | 462 +++++++ internal/app/browser_mode_test.go | 638 +++++++++ internal/app/browser_tabs_native_test.go | 125 ++ internal/app/character_attributes.go | 262 ++++ internal/app/character_attributes_test.go | 81 ++ internal/app/character_color_names.go | 147 +++ internal/app/clipboard.go | 65 + internal/app/clipboard_copy.go | 45 + internal/app/clipboard_copy_test.go | 110 ++ internal/app/clipboard_test.go | 52 + internal/app/clipboard_watch.go | 179 +++ internal/app/clipboard_watch_test.go | 99 ++ internal/app/combo_presentation.go | 39 + internal/app/combo_presentation_test.go | 60 + internal/app/command_input_test.go | 109 ++ internal/app/configured_speaker.go | 295 +++++ .../app/configured_speaker_native_test.go | 90 ++ internal/app/control_events_test.go | 284 ++++ internal/app/date_time.go | 26 + internal/app/date_time_test.go | 141 ++ internal/app/document_line_test.go | 313 +++++ internal/app/document_native_test.go | 976 ++++++++++++++ internal/app/document_review.go | 441 +++++++ internal/app/document_review_test.go | 424 ++++++ internal/app/echo_preferences_test.go | 160 +++ internal/app/echo_speaker.go | 250 ++++ internal/app/echo_speaker_test.go | 356 +++++ internal/app/event_accessibility.go | 285 ++++ internal/app/event_autocomplete.go | 93 ++ internal/app/event_browser_tabs.go | 74 ++ internal/app/event_caret.go | 98 ++ internal/app/event_caret_test.go | 226 ++++ internal/app/event_filters.go | 73 ++ internal/app/event_focus.go | 152 +++ internal/app/event_focus_ownership.go | 130 ++ internal/app/event_focus_test.go | 414 ++++++ internal/app/event_gui_table_test.go | 233 ++++ internal/app/event_lifecycle.go | 161 +++ internal/app/event_loop.go | 324 +++++ internal/app/event_navigation_filters.go | 98 ++ internal/app/event_notification.go | 128 ++ internal/app/event_presentation.go | 245 ++++ internal/app/event_presentation_test.go | 181 +++ internal/app/event_review_results.go | 278 ++++ internal/app/event_state.go | 187 +++ internal/app/event_text_selection.go | 74 ++ internal/app/event_tooltip.go | 30 + internal/app/event_tooltip_test.go | 89 ++ internal/app/flat_review.go | 105 ++ internal/app/flat_review_test.go | 196 +++ internal/app/focus_gate.go | 163 +++ internal/app/foreground_test.go | 203 +++ internal/app/gui_table_presentation.go | 99 ++ internal/app/gui_table_test.go | 95 ++ internal/app/i38lock_feedback_native_test.go | 192 +++ internal/app/indentation.go | 89 ++ internal/app/input_events.go | 123 ++ internal/app/input_events_test.go | 168 +++ internal/app/internal_ui.go | 388 ++++++ internal/app/internal_ui_native_test.go | 218 ++++ internal/app/internal_ui_output_test.go | 44 + internal/app/internal_ui_test.go | 131 ++ internal/app/key_bindings_test.go | 132 ++ internal/app/learn_mode.go | 257 ++++ internal/app/learn_mode_test.go | 81 ++ internal/app/linked_headings_native_test.go | 166 +++ internal/app/live_region_test.go | 83 ++ internal/app/live_settings.go | 35 + internal/app/mouse_review.go | 182 +++ internal/app/mouse_review_test.go | 47 + internal/app/named_links_native_test.go | 139 ++ internal/app/notification_list_test.go | 141 ++ internal/app/notification_test.go | 138 ++ internal/app/notifications.go | 276 ++++ internal/app/object_review_events_test.go | 228 ++++ internal/app/ocr_activate.go | 242 ++++ internal/app/ocr_commands.go | 227 ++++ internal/app/ocr_copy.go | 86 ++ internal/app/ocr_copy_test.go | 28 + internal/app/ocr_native_test.go | 276 ++++ internal/app/ocr_read_all.go | 48 + internal/app/ocr_reading_test.go | 162 +++ internal/app/ocr_refresh.go | 89 ++ internal/app/ocr_refresh_activation_test.go | 193 +++ internal/app/ocr_selection.go | 55 + internal/app/ocr_session.go | 690 ++++++++++ internal/app/ocr_session_test.go | 719 ++++++++++ internal/app/output_queue.go | 71 + internal/app/output_queue_test.go | 54 + internal/app/pause_chunks_test.go | 192 +++ internal/app/pending_presentation.go | 57 + internal/app/preferences.go | 168 +++ internal/app/preferences_application.go | 87 ++ internal/app/preferences_application_test.go | 87 ++ internal/app/preferences_apply.go | 223 ++++ internal/app/preferences_feedback_test.go | 198 +++ internal/app/preferences_preview.go | 78 ++ internal/app/preferences_session.go | 243 ++++ internal/app/preferences_session_test.go | 161 +++ internal/app/preferences_tentacles.go | 73 ++ internal/app/preferences_test.go | 361 ++++++ internal/app/progress_bars.go | 115 ++ internal/app/progress_bars_test.go | 181 +++ internal/app/progress_native_test.go | 128 ++ internal/app/progress_settings.go | 35 + internal/app/prompt_presentation.go | 56 + internal/app/prompt_presentation_test.go | 450 +++++++ internal/app/read_all.go | 96 ++ internal/app/read_all_test.go | 181 +++ internal/app/reading_context.go | 110 ++ internal/app/reading_preferences_test.go | 307 +++++ internal/app/reading_speech.go | 99 ++ internal/app/reading_speech_test.go | 95 ++ internal/app/remote.go | 153 +++ internal/app/remote_control.go | 336 +++++ internal/app/remote_control_test.go | 70 + internal/app/remote_test.go | 140 ++ internal/app/review_spelling.go | 59 + internal/app/review_spelling_test.go | 97 ++ internal/app/runtime_backends.go | 131 ++ internal/app/runtime_commands.go | 280 ++++ internal/app/runtime_commands_test.go | 317 +++++ internal/app/runtime_notifications.go | 57 + internal/app/runtime_platform.go | 51 + internal/app/runtime_platform_test.go | 104 ++ internal/app/runtime_remote.go | 130 ++ internal/app/runtime_review.go | 64 + internal/app/runtime_settings.go | 117 ++ internal/app/runtime_settings_test.go | 117 ++ internal/app/runtime_shutdown.go | 106 ++ internal/app/runtime_test.go | 251 ++++ internal/app/runtime_workers.go | 96 ++ internal/app/screen_lock.go | 209 +++ internal/app/screen_lock_test.go | 204 +++ internal/app/selection_native_test.go | 110 ++ internal/app/speech_delivery.go | 238 ++++ internal/app/speech_enable_test.go | 62 + internal/app/speech_history.go | 105 ++ internal/app/speech_history_test.go | 230 ++++ internal/app/speech_history_window.go | 261 ++++ internal/app/speech_interrupt.go | 208 +++ internal/app/speech_mode.go | 89 ++ internal/app/speech_mode_test.go | 182 +++ internal/app/speech_presentation.go | 37 + internal/app/speech_settings.go | 212 +++ internal/app/speech_settings_test.go | 209 +++ internal/app/speech_title_input.go | 24 + internal/app/speech_title_input_test.go | 79 ++ internal/app/speech_uninterruptible.go | 56 + internal/app/speech_worker.go | 414 ++++++ internal/app/speech_worker_test.go | 412 ++++++ internal/app/startup_sound_test.go | 104 ++ internal/app/steam_activation_test.go | 274 ++++ internal/app/table_native_test.go | 166 +++ internal/app/tentacle_clipboard.go | 136 ++ internal/app/tentacle_clipboard_test.go | 92 ++ internal/app/tentacle_install_test.go | 213 +++ internal/app/tentacle_interaction.go | 231 ++++ internal/app/tentacle_interaction_test.go | 194 +++ internal/app/tentacle_management.go | 624 +++++++++ internal/app/tentacle_management_test.go | 283 ++++ internal/app/tentacle_output.go | 144 ++ internal/app/tentacle_output_test.go | 215 +++ internal/app/tentacle_repositories.go | 52 + internal/app/tentacle_repositories_test.go | 109 ++ internal/app/tentacle_settings_test.go | 84 ++ internal/app/tentacle_uninterruptible_test.go | 227 ++++ internal/app/tentacles.go | 424 ++++++ internal/app/tentacles_test.go | 467 +++++++ internal/app/terminal_events_test.go | 212 +++ internal/app/test_helpers_test.go | 247 ++++ internal/app/text_selection.go | 91 ++ internal/app/text_selection_test.go | 213 +++ internal/app/version.go | 16 + internal/app/version_test.go | 30 + internal/app/webscripts.go | 98 ++ internal/app/webscripts_test.go | 114 ++ internal/app/window_owner.go | 154 +++ internal/app/window_owner_test.go | 106 ++ internal/app/window_speech_test.go | 100 ++ internal/app/window_titles.go | 249 ++++ internal/app/window_titles_native_test.go | 87 ++ internal/app/window_titles_test.go | 397 ++++++ internal/app/wine_events.go | 217 ++++ internal/app/wine_events_native_test.go | 114 ++ internal/app/wine_events_test.go | 285 ++++ internal/app/wine_review_capture.go | 59 + internal/app/wine_review_capture_test.go | 85 ++ internal/app/wine_review_focus.go | 125 ++ internal/app/wine_review_focus_test.go | 213 +++ internal/app/wine_review_native_test.go | 369 ++++++ internal/app/wine_window_titles.go | 62 + internal/app/xterm_handoff_test.go | 262 ++++ internal/atspi/adapter.go | 408 ++++++ internal/atspi/adapter_actions_native.go | 161 +++ internal/atspi/adapter_capture.c | 903 +++++++++++++ internal/atspi/adapter_document_native.go | 508 ++++++++ .../atspi/adapter_event_callback_native.go | 513 ++++++++ internal/atspi/adapter_events.c | 582 +++++++++ internal/atspi/adapter_foreground_native.go | 235 ++++ internal/atspi/adapter_lifecycle_native.go | 205 +++ internal/atspi/adapter_native.h | 128 ++ internal/atspi/adapter_native_test.go | 795 ++++++++++++ internal/atspi/adapter_ocr_native.go | 147 +++ internal/atspi/adapter_position.c | 127 ++ internal/atspi/adapter_references_native.go | 383 ++++++ internal/atspi/adapter_secure_native.go | 93 ++ internal/atspi/adapter_snapshot_native.go | 185 +++ internal/atspi/adapter_tree_native.go | 580 +++++++++ internal/atspi/adapter_tree_test.go | 409 ++++++ internal/atspi/adapter_window_title.go | 36 + internal/atspi/alert_native_test.go | 313 +++++ internal/atspi/braille_native.go | 142 ++ internal/atspi/braille_test.go | 119 ++ internal/atspi/browser_native_test.go | 483 +++++++ internal/atspi/catalog_native_test.go | 99 ++ internal/atspi/character_attributes_native.go | 102 ++ internal/atspi/document.go | 158 +++ internal/atspi/document_caret.go | 40 + internal/atspi/document_caret_echo.go | 37 + internal/atspi/document_caret_test.go | 15 + internal/atspi/document_line.go | 131 ++ internal/atspi/document_lines_native.go | 118 ++ internal/atspi/document_lines_native_test.go | 65 + internal/atspi/document_rows.go | 84 ++ internal/atspi/document_rows_test.go | 54 + internal/atspi/document_test.go | 369 ++++++ internal/atspi/document_text_lines.go | 47 + internal/atspi/document_text_lines_test.go | 46 + internal/atspi/firefox_native_test.go | 176 +++ internal/atspi/foreground.go | 55 + internal/atspi/foreground_test.go | 74 ++ internal/atspi/geometry.go | 69 + internal/atspi/geometry_test.go | 136 ++ internal/atspi/model.go | 423 ++++++ internal/atspi/model_test.go | 233 ++++ internal/atspi/mouse_review_native.go | 238 ++++ internal/atspi/mouse_review_native_test.go | 81 ++ internal/atspi/ocr_geometry.go | 97 ++ internal/atspi/ocr_geometry_test.go | 48 + internal/atspi/ocr_native_test.go | 141 ++ internal/atspi/position_native_test.go | 65 + internal/atspi/preferences_native_test.go | 192 +++ internal/atspi/read_all_native.go | 130 ++ internal/atspi/review.go | 95 ++ internal/atspi/review_test.go | 99 ++ internal/atspi/session_native_test.go | 47 + internal/atspi/speech_metadata_native.go | 151 +++ internal/atspi/speech_metadata_native_test.go | 125 ++ internal/atspi/tentacle_item_native.go | 82 ++ internal/atspi/text.go | 324 +++++ internal/atspi/text_attributes.go | 18 + internal/atspi/text_native.go | 248 ++++ internal/atspi/text_test.go | 386 ++++++ internal/atspi/tree.go | 554 ++++++++ internal/atspi/tree_test.go | 408 ++++++ internal/atspi/version.go | 32 + internal/atspi/version_test.go | 36 + internal/atspi/webscripts_native.go | 218 ++++ internal/atspi/webscripts_native_test.go | 141 ++ internal/atspi/window_title_native_test.go | 100 ++ internal/braille/braille.go | 276 ++++ internal/braille/braille_test.go | 222 ++++ internal/braille/client.go | 120 ++ internal/braille/client_test.go | 111 ++ internal/braille/native.go | 225 ++++ internal/braille/native_test.go | 20 + internal/braille/regions.go | 42 + internal/braille/regions_test.go | 35 + internal/braille/tables.go | 56 + internal/braille/tables_test.go | 23 + internal/braille/translate_native.go | 117 ++ internal/braille/translate_native_test.go | 73 ++ internal/braille/translate_unavailable.go | 9 + internal/braille/unavailable.go | 12 + internal/braille/unavailable_test.go | 21 + internal/clipboard/clipboard.go | 109 ++ internal/clipboard/clipboard_test.go | 56 + internal/clipboard/native.go | 68 + internal/clipboard/watch.c | 70 + internal/clipboard/watch.go | 100 ++ internal/clipboard/watch.h | 17 + internal/config/application_store_test.go | 53 + internal/config/bindings_test.go | 42 + internal/config/clipboard_test.go | 16 + internal/config/codec.go | 145 +++ internal/config/config.go | 109 ++ internal/config/config_test.go | 284 ++++ internal/config/defaults.go | 89 ++ internal/config/hardware_test.go | 61 + internal/config/legacy.go | 140 ++ internal/config/legacy_mapping.go | 335 +++++ internal/config/ocr_test.go | 48 + internal/config/patch_contract_test.go | 69 + internal/config/profile_mapping.go | 576 ++++++++ internal/config/progress_test.go | 90 ++ internal/config/schema.go | 196 +++ internal/config/settings.go | 212 +++ internal/config/settings_copy.go | 56 + internal/config/store.go | 285 ++++ internal/config/store_test.go | 387 ++++++ internal/config/testdata/settings-v1.toml | 34 + internal/config/testdata/settings-v2.toml | 17 + internal/config/testdata/settings-v3.toml | 4 + internal/config/text_preferences_test.go | 37 + internal/config/validation.go | 259 ++++ internal/config/voices.go | 27 + internal/config/voices_test.go | 86 ++ internal/config/window_titles_test.go | 58 + internal/datetime/datetime.go | 154 +++ internal/input/bindings.go | 292 +++++ internal/input/bindings_test.go | 122 ++ internal/input/clipboard_test.go | 43 + internal/input/commands.go | 459 +++++++ internal/input/commands_test.go | 298 +++++ internal/input/date_time_test.go | 47 + internal/input/document_test.go | 50 + internal/input/flat_review_test.go | 185 +++ internal/input/input.go | 125 ++ internal/input/input_test.go | 89 ++ internal/input/intent.go | 135 ++ internal/input/intent_test.go | 85 ++ internal/input/notification_commands_test.go | 30 + internal/input/ocr.go | 75 ++ internal/input/ocr_test.go | 84 ++ internal/input/read_all_test.go | 29 + internal/input/repeat.go | 31 + internal/input/repeat_test.go | 39 + internal/input/review_presses.go | 39 + internal/input/speech_history_test.go | 20 + internal/input/speech_mode_test.go | 36 + internal/input/sticky_test.go | 55 + internal/input/tentacle_bindings.go | 46 + internal/input/tentacle_bindings_test.go | 62 + internal/input/version_test.go | 17 + internal/interaction/session.go | 467 +++++++ internal/interaction/session_test.go | 173 +++ internal/lifecycle/lifecycle.go | 154 +++ internal/lifecycle/lifecycle_test.go | 176 +++ internal/logging/async.go | 399 ++++++ internal/logging/async_test.go | 334 +++++ internal/logging/logging.go | 231 ++++ internal/logging/logging_test.go | 158 +++ internal/notification/mako.go | 395 ++++++ internal/notification/mako_test.go | 228 ++++ internal/notification/presenter.go | 442 +++++++ internal/notification/presenter_test.go | 123 ++ internal/notificationui/native.c | 263 ++++ internal/notificationui/native.h | 21 + internal/notificationui/protocol.go | 35 + internal/notificationui/ui.go | 200 +++ internal/nvda2cthulhu/messagepack.go | 210 +++ internal/nvda2cthulhu/messagepack_test.go | 44 + internal/nvda2cthulhu/protocol.go | 154 +++ internal/nvda2cthulhu/protocol_test.go | 40 + internal/nvda2cthulhu/server.go | 196 +++ internal/nvda2cthulhu/server_test.go | 171 +++ internal/ocr/capture.go | 247 ++++ internal/ocr/capture_native_test.go | 231 ++++ internal/ocr/capture_test.go | 232 ++++ internal/ocr/cursor.go | 169 +++ internal/ocr/cursor_continuous_test.go | 23 + internal/ocr/cursor_test.go | 59 + internal/ocr/engine.go | 252 ++++ internal/ocr/engine_test.go | 431 ++++++ internal/ocr/model.go | 157 +++ internal/ocr/model_test.go | 90 ++ internal/ocr/native_test.go | 74 ++ internal/ocr/page_test.go | 30 + internal/ocr/preprocess.go | 57 + internal/ocr/preprocess_test.go | 47 + internal/ocr/process.go | 107 ++ internal/ocr/testdata/painted-text.png | Bin 0 -> 11544 bytes internal/ocr/tsv.go | 138 ++ internal/ocr/tsv_test.go | 135 ++ internal/ocrcapture/capture.go | 89 ++ internal/ocrcapture/native.c | 154 +++ internal/ocrcapture/native.h | 20 + internal/ocrconfirm/confirm.c | 24 + internal/ocrconfirm/confirm.go | 35 + internal/parity/parity.go | 177 +++ internal/parity/parity_test.go | 88 ++ internal/platform/session/session.go | 31 + internal/platform/session/session_test.go | 44 + internal/platform/xorg/applications.go | 33 + internal/platform/xorg/clipboard.go | 124 ++ .../platform/xorg/clipboard_native_test.go | 41 + internal/platform/xorg/commands.go | 114 ++ internal/platform/xorg/commands_bindings.go | 220 ++++ internal/platform/xorg/commands_chord.go | 178 +++ internal/platform/xorg/commands_foreground.go | 47 + internal/platform/xorg/commands_native.c | 575 ++++++++ internal/platform/xorg/commands_native.h | 54 + internal/platform/xorg/commands_ocr.go | 117 ++ internal/platform/xorg/commands_owner.go | 113 ++ internal/platform/xorg/commands_owner_loop.go | 280 ++++ internal/platform/xorg/commands_security.go | 54 + internal/platform/xorg/commands_ui.go | 175 +++ internal/platform/xorg/listener.go | 399 ++++++ .../platform/xorg/listener_native_test.go | 1133 ++++++++++++++++ internal/platform/xorg/locks_native_test.go | 128 ++ internal/platform/xorg/ocr_click.c | 50 + internal/platform/xorg/ocr_click.go | 36 + .../platform/xorg/ocr_click_native_test.go | 83 ++ internal/platform/xorg/ocr_native_test.go | 348 +++++ internal/platform/xorg/pointer.go | 44 + .../platform/xorg/scroll_lock_native_test.go | 178 +++ .../platform/xorg/tentacle_native_test.go | 197 +++ internal/platform/xorg/ui_native_test.go | 206 +++ internal/platform/xorg/window_title.c | 272 ++++ internal/platform/xorg/window_title.go | 42 + internal/platform/xorg/window_title.h | 21 + .../platform/xorg/window_title_native_test.go | 108 ++ .../xorg/xterm_handoff_native_test.go | 191 +++ internal/preferences/applications.go | 135 ++ internal/preferences/applications_test.go | 23 + internal/preferences/bindings.go | 147 +++ internal/preferences/braille.go | 111 ++ internal/preferences/catalog_protocol.go | 124 ++ internal/preferences/catalog_protocol_test.go | 113 ++ internal/preferences/catalog_voices.go | 32 + internal/preferences/echo.go | 172 +++ internal/preferences/general.go | 79 ++ internal/preferences/native.c | 280 ++++ internal/preferences/native.h | 68 + internal/preferences/native_applications.c | 136 ++ internal/preferences/native_applications.h | 14 + internal/preferences/native_bindings.c | 155 +++ internal/preferences/native_internal.h | 18 + internal/preferences/native_reading.c | 162 +++ internal/preferences/native_tentacles.c | 163 +++ internal/preferences/native_text_attributes.c | 200 +++ internal/preferences/native_widgets.c | 84 ++ internal/preferences/ocr.go | 53 + internal/preferences/preferences.ui | 318 +++++ internal/preferences/protocol.go | 110 ++ internal/preferences/protocol_test.go | 142 ++ internal/preferences/reading.go | 74 ++ internal/preferences/session.go | 37 + internal/preferences/sound.go | 78 ++ internal/preferences/speech.go | 401 ++++++ internal/preferences/tentacles.go | 282 ++++ internal/preferences/tentacles_test.go | 40 + internal/preferences/text_attributes.go | 97 ++ internal/preferences/ui.go | 201 +++ internal/preferences/ui_values.go | 47 + internal/preferences/values.go | 131 ++ internal/preferences/widgets.go | 100 ++ internal/presentation/control_values.go | 203 +++ internal/presentation/control_values_test.go | 205 +++ internal/presentation/document_line.go | 127 ++ internal/presentation/document_line_test.go | 88 ++ internal/presentation/flat_review.go | 42 + internal/presentation/flat_review_test.go | 37 + internal/presentation/position_sound_test.go | 80 ++ internal/presentation/presentation.go | 306 +++++ internal/presentation/presentation_test.go | 294 +++++ internal/presentation/reading.go | 191 +++ internal/presentation/reading_test.go | 111 ++ internal/presentation/sound_cues.go | 206 +++ internal/presentation/sound_cues_test.go | 251 ++++ internal/presentation/speech.go | 65 + internal/presentation/typing_echo.go | 143 ++ internal/presentation/typing_echo_test.go | 125 ++ internal/remote/endpoint.go | 273 ++++ internal/remote/endpoint_test.go | 207 +++ internal/remote/introspection.go | 59 + internal/remote/modules.go | 386 ++++++ internal/remote/modules_test.go | 239 ++++ internal/remote/registrations.go | 85 ++ internal/remote/service.go | 322 +++++ internal/remote/service_test.go | 324 +++++ internal/remote/wine_review.go | 37 + internal/remote/wine_review_test.go | 121 ++ internal/review/cursor.go | 198 +++ internal/review/cursor_test.go | 193 +++ internal/review/document.go | 609 +++++++++ internal/review/document_activation_test.go | 81 ++ internal/review/document_braille.go | 75 ++ internal/review/document_braille_routing.go | 133 ++ .../review/document_braille_routing_test.go | 91 ++ internal/review/document_braille_test.go | 81 ++ internal/review/document_caret.go | 110 ++ internal/review/document_caret_glyph_test.go | 137 ++ internal/review/document_caret_line.go | 168 +++ internal/review/document_caret_nodes.go | 100 ++ internal/review/document_caret_test.go | 490 +++++++ internal/review/document_caret_text.go | 112 ++ internal/review/document_line_worker.go | 86 ++ internal/review/document_mode.go | 185 +++ internal/review/document_mode_test.go | 219 ++++ internal/review/document_native_caret.go | 121 ++ internal/review/document_native_caret_test.go | 115 ++ internal/review/document_requests.go | 69 + internal/review/document_table.go | 167 +++ internal/review/document_table_test.go | 128 ++ internal/review/document_test.go | 514 ++++++++ internal/review/document_visual_line.go | 293 +++++ .../document_visual_line_native_test.go | 181 +++ internal/review/document_visual_line_test.go | 259 ++++ internal/review/document_worker.go | 492 +++++++ internal/review/document_worker_test.go | 927 +++++++++++++ internal/review/flat.go | 391 ++++++ internal/review/flat_layout.go | 278 ++++ internal/review/flat_native_test.go | 312 +++++ internal/review/flat_pluma_native_test.go | 81 ++ internal/review/flat_test.go | 546 ++++++++ internal/review/flat_worker.go | 132 ++ internal/review/flat_worker_test.go | 265 ++++ internal/review/interest.go | 111 ++ internal/review/interest_test.go | 132 ++ internal/review/object_worker.go | 112 ++ internal/review/read_all.go | 218 ++++ internal/review/read_all_test.go | 218 ++++ internal/review/worker.go | 374 ++++++ internal/review/worker_contract.go | 134 ++ internal/review/worker_test.go | 405 ++++++ internal/sessionbus/sessionbus.go | 61 + internal/sessionbus/sessionbus_test.go | 107 ++ internal/shortcutui/native.c | 51 + internal/shortcutui/ui.go | 55 + internal/shortcutui/ui_native_test.go | 39 + internal/sound/indentation.go | 25 + internal/sound/indentation_test.go | 36 + internal/sound/player.go | 110 ++ internal/sound/position.go | 33 + internal/sound/position_test.go | 98 ++ internal/sound/progress.go | 64 + internal/sound/progress_test.go | 185 +++ internal/sound/service.go | 432 ++++++ internal/sound/service_test.go | 628 +++++++++ internal/sound/speech_beep.go | 24 + internal/sound/speech_beep_test.go | 28 + internal/sound/tone.go | 45 + internal/sound/tone_test.go | 19 + internal/speech/address.go | 84 ++ internal/speech/address_test.go | 100 ++ internal/speech/autospawn.go | 95 ++ internal/speech/autospawn_test.go | 183 +++ internal/speech/catalog.go | 84 ++ internal/speech/catalog_test.go | 43 + internal/speech/hardware.go | 163 +++ internal/speech/hardware_test.go | 29 + internal/speech/locale_voices.go | 49 + internal/speech/read_all.go | 79 ++ internal/speech/read_all_test.go | 84 ++ internal/speech/serial_native.go | 65 + internal/speech/serial_native_test.go | 120 ++ internal/speech/serial_unavailable.go | 14 + internal/speech/settings_test.go | 258 ++++ internal/speech/speech.go | 471 +++++++ internal/speech/speech_test.go | 471 +++++++ internal/speechhistoryui/native.c | 221 ++++ internal/speechhistoryui/native.h | 14 + internal/speechhistoryui/ui.go | 151 +++ internal/tentacle/bindings.go | 88 ++ internal/tentacle/containment.go | 198 +++ internal/tentacle/containment_test.go | 386 ++++++ internal/tentacle/delegation.go | 161 +++ internal/tentacle/delegation_test.go | 99 ++ internal/tentacle/install.go | 427 ++++++ internal/tentacle/install_test.go | 395 ++++++ internal/tentacle/launch.go | 203 +++ internal/tentacle/load_test.go | 114 ++ internal/tentacle/local.go | 92 ++ internal/tentacle/local_test.go | 43 + internal/tentacle/management.go | 27 + internal/tentacle/manager.go | 193 +++ internal/tentacle/process.go | 298 +++++ internal/tentacle/protocol.go | 176 +++ internal/tentacle/protocol_test.go | 95 ++ internal/tentacle/registry.go | 245 ++++ internal/tentacle/registry_test.go | 105 ++ internal/tentacle/repository.go | 285 ++++ internal/tentacle/repository_test.go | 177 +++ internal/tentacle/sdk_test.go | 252 ++++ internal/tentacle/session.go | 290 +++++ internal/tentacle/session_test.go | 565 ++++++++ internal/tentacle/testdata/peer.py | 26 + internal/tentacle/testdata/resource_peer.py | 63 + internal/tentacle/testdata/sdk_peer.py | 41 + internal/tentaclewire/interaction.go | 108 ++ internal/tentaclewire/interaction_test.go | 57 + internal/tentaclewire/presentation.go | 73 ++ internal/tentaclewire/presentation_test.go | 37 + internal/tentaclewire/wire.go | 106 ++ internal/version/version.go | 40 + internal/version/version_test.go | 53 + internal/webscripts/registry.go | 72 + internal/webscripts/steam.go | 182 +++ internal/webscripts/steam_test.go | 123 ++ internal/wine/manager.go | 326 +++++ internal/wine/manager_test.go | 299 +++++ internal/wine/review_client.go | 91 ++ internal/wine/review_protocol.go | 82 ++ internal/wine/review_registry.go | 228 ++++ internal/wine/review_registry_test.go | 174 +++ internal/wine/review_snapshot.go | 331 +++++ internal/wine/review_snapshot_test.go | 352 +++++ sdk/go/tentacle/tentacle.go | 237 ++++ sdk/python/cthulhu_tentacle/__init__.py | 214 +++ testdata/parity-ledger.csv | 56 + tests/browser-catalog.html | 26 + tests/browser-fragments.html | 26 + tests/browser-lines.html | 15 + tests/browser-linked-headings.html | 14 + tests/browser-named-links.html | 12 + tests/browser-next.html | 7 + tests/browser-positions.html | 28 + tests/browser.html | 45 + tests/firefox-baseline.html | 10 + tests/gtk_alert.py | 33 + tests/gtk_progress.py | 46 + tests/gtk_radio_group.py | 19 + tests/gtk_selection.py | 35 + tests/gtk_table.py | 47 + tests/gtk_tree.py | 161 +++ tests/gtk_web_notification.py | 71 + tests/native_alert.py | 82 ++ tests/native_atspi.py | 198 +++ tests/native_browser.py | 135 ++ tests/native_clipboard_preferences.py | 221 ++++ tests/native_firefox.py | 110 ++ tests/native_internal_ui.py | 117 ++ tests/native_notifications.py | 174 +++ tests/native_ownership.py | 355 +++++ tests/native_preferences.py | 1154 +++++++++++++++++ tests/native_progress.py | 40 + tests/native_speech_history.py | 146 +++ tests/native_tentacle_settings.py | 47 + tests/native_webscripts.py | 43 + tests/native_x11.py | 727 +++++++++++ tests/ocr_capture_fixture.py | 135 ++ tests/ocr_click_fixture.py | 34 + tests/ocr_geometry_fixture.py | 89 ++ tests/ocr_review_fixture.py | 101 ++ tests/ocr_session_fixture.py | 125 ++ tests/run-delegated-reader.sh | 26 + tests/steam_big_picture_log.py | 94 ++ tests/text-preferences.html | 9 + tests/vte_terminal.py | 26 + tests/window_title_fixture.py | 69 + tests/wine_review_x11.py | 153 +++ tests/x11_focus_child.py | 45 + tests/xorg_suspension_probe.c | 66 + 708 files changed, 112482 insertions(+), 532 deletions(-) create mode 100644 .gitignore create mode 100644 AUTHORS create mode 100644 LICENSE create mode 100644 assets/sounds/default/autocomplete.wav create mode 100644 assets/sounds/default/browse_mode.wav create mode 100644 assets/sounds/default/button.wav create mode 100644 assets/sounds/default/checkbox_checked.wav create mode 100644 assets/sounds/default/checkbox_mixed.wav create mode 100644 assets/sounds/default/checkbox_unchecked.wav create mode 100644 assets/sounds/default/combobox.wav create mode 100644 assets/sounds/default/focus_mode.wav create mode 100644 assets/sounds/default/link.wav create mode 100644 assets/sounds/default/link_visited.wav create mode 100644 assets/sounds/default/radiobutton_checked.wav create mode 100644 assets/sounds/default/radiobutton_unchecked.wav create mode 100644 assets/sounds/default/start.wav create mode 100644 assets/sounds/default/stop.wav create mode 100644 assets/sounds/default/switch_off.wav create mode 100644 assets/sounds/default/switch_on.wav create mode 100644 assets/sounds/default/togglebutton_checked.wav create mode 100644 assets/sounds/default/togglebutton_unchecked.wav create mode 100644 assets/sounds/embed.go create mode 100644 cmd/cthulhu-braille/main.go create mode 100644 cmd/cthulhu/main.go create mode 100644 cmd/cthulhu/main_test.go create mode 100644 cmd/cthulhu/replace.go create mode 100644 cmd/cthulhu/replace_test.go create mode 100644 cmd/parityledger/main.go create mode 100644 cmd/parityledger/main_test.go create mode 100644 cmd/tentacle-host/main.go create mode 100644 distro-packages/Arch-Linux/cthulhu-git/.SRCINFO delete mode 100644 distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD delete mode 100644 distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD delete mode 100644 distro-packages/Arch-Linux/cthulhu/PKGBUILD delete mode 100755 distro-packages/Slackware/cthulhu.SlackBuild delete mode 100644 distro-packages/Slackware/doinst.sh delete mode 100644 distro-packages/Slackware/slack-desc delete mode 100644 distro-packages/Slint/README delete mode 100644 distro-packages/Slint/cthulhu-info delete mode 100755 distro-packages/Slint/cthulhu.SlackBuild delete mode 100644 distro-packages/Slint/doinst.sh delete mode 100644 distro-packages/Slint/slack-desc create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go create mode 100644 internal/app/application_focus_test.go create mode 100644 internal/app/autocomplete.go create mode 100644 internal/app/braille_context.go create mode 100644 internal/app/braille_context_test.go create mode 100644 internal/app/braille_frames.go create mode 100644 internal/app/braille_helper_test.go create mode 100644 internal/app/braille_presentation.go create mode 100644 internal/app/braille_presentation_test.go create mode 100644 internal/app/braille_routing.go create mode 100644 internal/app/braille_session.go create mode 100644 internal/app/braille_text_attributes.go create mode 100644 internal/app/braille_text_attributes_test.go create mode 100644 internal/app/braille_worker.go create mode 100644 internal/app/braille_worker_test.go create mode 100644 internal/app/browser_dynamic_test.go create mode 100644 internal/app/browser_events_test.go create mode 100644 internal/app/browser_mode.go create mode 100644 internal/app/browser_mode_test.go create mode 100644 internal/app/browser_tabs_native_test.go create mode 100644 internal/app/character_attributes.go create mode 100644 internal/app/character_attributes_test.go create mode 100644 internal/app/character_color_names.go create mode 100644 internal/app/clipboard.go create mode 100644 internal/app/clipboard_copy.go create mode 100644 internal/app/clipboard_copy_test.go create mode 100644 internal/app/clipboard_test.go create mode 100644 internal/app/clipboard_watch.go create mode 100644 internal/app/clipboard_watch_test.go create mode 100644 internal/app/combo_presentation.go create mode 100644 internal/app/combo_presentation_test.go create mode 100644 internal/app/command_input_test.go create mode 100644 internal/app/configured_speaker.go create mode 100644 internal/app/configured_speaker_native_test.go create mode 100644 internal/app/control_events_test.go create mode 100644 internal/app/date_time.go create mode 100644 internal/app/date_time_test.go create mode 100644 internal/app/document_line_test.go create mode 100644 internal/app/document_native_test.go create mode 100644 internal/app/document_review.go create mode 100644 internal/app/document_review_test.go create mode 100644 internal/app/echo_preferences_test.go create mode 100644 internal/app/echo_speaker.go create mode 100644 internal/app/echo_speaker_test.go create mode 100644 internal/app/event_accessibility.go create mode 100644 internal/app/event_autocomplete.go create mode 100644 internal/app/event_browser_tabs.go create mode 100644 internal/app/event_caret.go create mode 100644 internal/app/event_caret_test.go create mode 100644 internal/app/event_filters.go create mode 100644 internal/app/event_focus.go create mode 100644 internal/app/event_focus_ownership.go create mode 100644 internal/app/event_focus_test.go create mode 100644 internal/app/event_gui_table_test.go create mode 100644 internal/app/event_lifecycle.go create mode 100644 internal/app/event_loop.go create mode 100644 internal/app/event_navigation_filters.go create mode 100644 internal/app/event_notification.go create mode 100644 internal/app/event_presentation.go create mode 100644 internal/app/event_presentation_test.go create mode 100644 internal/app/event_review_results.go create mode 100644 internal/app/event_state.go create mode 100644 internal/app/event_text_selection.go create mode 100644 internal/app/event_tooltip.go create mode 100644 internal/app/event_tooltip_test.go create mode 100644 internal/app/flat_review.go create mode 100644 internal/app/flat_review_test.go create mode 100644 internal/app/focus_gate.go create mode 100644 internal/app/foreground_test.go create mode 100644 internal/app/gui_table_presentation.go create mode 100644 internal/app/gui_table_test.go create mode 100644 internal/app/i38lock_feedback_native_test.go create mode 100644 internal/app/indentation.go create mode 100644 internal/app/input_events.go create mode 100644 internal/app/input_events_test.go create mode 100644 internal/app/internal_ui.go create mode 100644 internal/app/internal_ui_native_test.go create mode 100644 internal/app/internal_ui_output_test.go create mode 100644 internal/app/internal_ui_test.go create mode 100644 internal/app/key_bindings_test.go create mode 100644 internal/app/learn_mode.go create mode 100644 internal/app/learn_mode_test.go create mode 100644 internal/app/linked_headings_native_test.go create mode 100644 internal/app/live_region_test.go create mode 100644 internal/app/live_settings.go create mode 100644 internal/app/mouse_review.go create mode 100644 internal/app/mouse_review_test.go create mode 100644 internal/app/named_links_native_test.go create mode 100644 internal/app/notification_list_test.go create mode 100644 internal/app/notification_test.go create mode 100644 internal/app/notifications.go create mode 100644 internal/app/object_review_events_test.go create mode 100644 internal/app/ocr_activate.go create mode 100644 internal/app/ocr_commands.go create mode 100644 internal/app/ocr_copy.go create mode 100644 internal/app/ocr_copy_test.go create mode 100644 internal/app/ocr_native_test.go create mode 100644 internal/app/ocr_read_all.go create mode 100644 internal/app/ocr_reading_test.go create mode 100644 internal/app/ocr_refresh.go create mode 100644 internal/app/ocr_refresh_activation_test.go create mode 100644 internal/app/ocr_selection.go create mode 100644 internal/app/ocr_session.go create mode 100644 internal/app/ocr_session_test.go create mode 100644 internal/app/output_queue.go create mode 100644 internal/app/output_queue_test.go create mode 100644 internal/app/pause_chunks_test.go create mode 100644 internal/app/pending_presentation.go create mode 100644 internal/app/preferences.go create mode 100644 internal/app/preferences_application.go create mode 100644 internal/app/preferences_application_test.go create mode 100644 internal/app/preferences_apply.go create mode 100644 internal/app/preferences_feedback_test.go create mode 100644 internal/app/preferences_preview.go create mode 100644 internal/app/preferences_session.go create mode 100644 internal/app/preferences_session_test.go create mode 100644 internal/app/preferences_tentacles.go create mode 100644 internal/app/preferences_test.go create mode 100644 internal/app/progress_bars.go create mode 100644 internal/app/progress_bars_test.go create mode 100644 internal/app/progress_native_test.go create mode 100644 internal/app/progress_settings.go create mode 100644 internal/app/prompt_presentation.go create mode 100644 internal/app/prompt_presentation_test.go create mode 100644 internal/app/read_all.go create mode 100644 internal/app/read_all_test.go create mode 100644 internal/app/reading_context.go create mode 100644 internal/app/reading_preferences_test.go create mode 100644 internal/app/reading_speech.go create mode 100644 internal/app/reading_speech_test.go create mode 100644 internal/app/remote.go create mode 100644 internal/app/remote_control.go create mode 100644 internal/app/remote_control_test.go create mode 100644 internal/app/remote_test.go create mode 100644 internal/app/review_spelling.go create mode 100644 internal/app/review_spelling_test.go create mode 100644 internal/app/runtime_backends.go create mode 100644 internal/app/runtime_commands.go create mode 100644 internal/app/runtime_commands_test.go create mode 100644 internal/app/runtime_notifications.go create mode 100644 internal/app/runtime_platform.go create mode 100644 internal/app/runtime_platform_test.go create mode 100644 internal/app/runtime_remote.go create mode 100644 internal/app/runtime_review.go create mode 100644 internal/app/runtime_settings.go create mode 100644 internal/app/runtime_settings_test.go create mode 100644 internal/app/runtime_shutdown.go create mode 100644 internal/app/runtime_test.go create mode 100644 internal/app/runtime_workers.go create mode 100644 internal/app/screen_lock.go create mode 100644 internal/app/screen_lock_test.go create mode 100644 internal/app/selection_native_test.go create mode 100644 internal/app/speech_delivery.go create mode 100644 internal/app/speech_enable_test.go create mode 100644 internal/app/speech_history.go create mode 100644 internal/app/speech_history_test.go create mode 100644 internal/app/speech_history_window.go create mode 100644 internal/app/speech_interrupt.go create mode 100644 internal/app/speech_mode.go create mode 100644 internal/app/speech_mode_test.go create mode 100644 internal/app/speech_presentation.go create mode 100644 internal/app/speech_settings.go create mode 100644 internal/app/speech_settings_test.go create mode 100644 internal/app/speech_title_input.go create mode 100644 internal/app/speech_title_input_test.go create mode 100644 internal/app/speech_uninterruptible.go create mode 100644 internal/app/speech_worker.go create mode 100644 internal/app/speech_worker_test.go create mode 100644 internal/app/startup_sound_test.go create mode 100644 internal/app/steam_activation_test.go create mode 100644 internal/app/table_native_test.go create mode 100644 internal/app/tentacle_clipboard.go create mode 100644 internal/app/tentacle_clipboard_test.go create mode 100644 internal/app/tentacle_install_test.go create mode 100644 internal/app/tentacle_interaction.go create mode 100644 internal/app/tentacle_interaction_test.go create mode 100644 internal/app/tentacle_management.go create mode 100644 internal/app/tentacle_management_test.go create mode 100644 internal/app/tentacle_output.go create mode 100644 internal/app/tentacle_output_test.go create mode 100644 internal/app/tentacle_repositories.go create mode 100644 internal/app/tentacle_repositories_test.go create mode 100644 internal/app/tentacle_settings_test.go create mode 100644 internal/app/tentacle_uninterruptible_test.go create mode 100644 internal/app/tentacles.go create mode 100644 internal/app/tentacles_test.go create mode 100644 internal/app/terminal_events_test.go create mode 100644 internal/app/test_helpers_test.go create mode 100644 internal/app/text_selection.go create mode 100644 internal/app/text_selection_test.go create mode 100644 internal/app/version.go create mode 100644 internal/app/version_test.go create mode 100644 internal/app/webscripts.go create mode 100644 internal/app/webscripts_test.go create mode 100644 internal/app/window_owner.go create mode 100644 internal/app/window_owner_test.go create mode 100644 internal/app/window_speech_test.go create mode 100644 internal/app/window_titles.go create mode 100644 internal/app/window_titles_native_test.go create mode 100644 internal/app/window_titles_test.go create mode 100644 internal/app/wine_events.go create mode 100644 internal/app/wine_events_native_test.go create mode 100644 internal/app/wine_events_test.go create mode 100644 internal/app/wine_review_capture.go create mode 100644 internal/app/wine_review_capture_test.go create mode 100644 internal/app/wine_review_focus.go create mode 100644 internal/app/wine_review_focus_test.go create mode 100644 internal/app/wine_review_native_test.go create mode 100644 internal/app/wine_window_titles.go create mode 100644 internal/app/xterm_handoff_test.go create mode 100644 internal/atspi/adapter.go create mode 100644 internal/atspi/adapter_actions_native.go create mode 100644 internal/atspi/adapter_capture.c create mode 100644 internal/atspi/adapter_document_native.go create mode 100644 internal/atspi/adapter_event_callback_native.go create mode 100644 internal/atspi/adapter_events.c create mode 100644 internal/atspi/adapter_foreground_native.go create mode 100644 internal/atspi/adapter_lifecycle_native.go create mode 100644 internal/atspi/adapter_native.h create mode 100644 internal/atspi/adapter_native_test.go create mode 100644 internal/atspi/adapter_ocr_native.go create mode 100644 internal/atspi/adapter_position.c create mode 100644 internal/atspi/adapter_references_native.go create mode 100644 internal/atspi/adapter_secure_native.go create mode 100644 internal/atspi/adapter_snapshot_native.go create mode 100644 internal/atspi/adapter_tree_native.go create mode 100644 internal/atspi/adapter_tree_test.go create mode 100644 internal/atspi/adapter_window_title.go create mode 100644 internal/atspi/alert_native_test.go create mode 100644 internal/atspi/braille_native.go create mode 100644 internal/atspi/braille_test.go create mode 100644 internal/atspi/browser_native_test.go create mode 100644 internal/atspi/catalog_native_test.go create mode 100644 internal/atspi/character_attributes_native.go create mode 100644 internal/atspi/document.go create mode 100644 internal/atspi/document_caret.go create mode 100644 internal/atspi/document_caret_echo.go create mode 100644 internal/atspi/document_caret_test.go create mode 100644 internal/atspi/document_line.go create mode 100644 internal/atspi/document_lines_native.go create mode 100644 internal/atspi/document_lines_native_test.go create mode 100644 internal/atspi/document_rows.go create mode 100644 internal/atspi/document_rows_test.go create mode 100644 internal/atspi/document_test.go create mode 100644 internal/atspi/document_text_lines.go create mode 100644 internal/atspi/document_text_lines_test.go create mode 100644 internal/atspi/firefox_native_test.go create mode 100644 internal/atspi/foreground.go create mode 100644 internal/atspi/foreground_test.go create mode 100644 internal/atspi/geometry.go create mode 100644 internal/atspi/geometry_test.go create mode 100644 internal/atspi/model.go create mode 100644 internal/atspi/model_test.go create mode 100644 internal/atspi/mouse_review_native.go create mode 100644 internal/atspi/mouse_review_native_test.go create mode 100644 internal/atspi/ocr_geometry.go create mode 100644 internal/atspi/ocr_geometry_test.go create mode 100644 internal/atspi/ocr_native_test.go create mode 100644 internal/atspi/position_native_test.go create mode 100644 internal/atspi/preferences_native_test.go create mode 100644 internal/atspi/read_all_native.go create mode 100644 internal/atspi/review.go create mode 100644 internal/atspi/review_test.go create mode 100644 internal/atspi/session_native_test.go create mode 100644 internal/atspi/speech_metadata_native.go create mode 100644 internal/atspi/speech_metadata_native_test.go create mode 100644 internal/atspi/tentacle_item_native.go create mode 100644 internal/atspi/text.go create mode 100644 internal/atspi/text_attributes.go create mode 100644 internal/atspi/text_native.go create mode 100644 internal/atspi/text_test.go create mode 100644 internal/atspi/tree.go create mode 100644 internal/atspi/tree_test.go create mode 100644 internal/atspi/version.go create mode 100644 internal/atspi/version_test.go create mode 100644 internal/atspi/webscripts_native.go create mode 100644 internal/atspi/webscripts_native_test.go create mode 100644 internal/atspi/window_title_native_test.go create mode 100644 internal/braille/braille.go create mode 100644 internal/braille/braille_test.go create mode 100644 internal/braille/client.go create mode 100644 internal/braille/client_test.go create mode 100644 internal/braille/native.go create mode 100644 internal/braille/native_test.go create mode 100644 internal/braille/regions.go create mode 100644 internal/braille/regions_test.go create mode 100644 internal/braille/tables.go create mode 100644 internal/braille/tables_test.go create mode 100644 internal/braille/translate_native.go create mode 100644 internal/braille/translate_native_test.go create mode 100644 internal/braille/translate_unavailable.go create mode 100644 internal/braille/unavailable.go create mode 100644 internal/braille/unavailable_test.go create mode 100644 internal/clipboard/clipboard.go create mode 100644 internal/clipboard/clipboard_test.go create mode 100644 internal/clipboard/native.go create mode 100644 internal/clipboard/watch.c create mode 100644 internal/clipboard/watch.go create mode 100644 internal/clipboard/watch.h create mode 100644 internal/config/application_store_test.go create mode 100644 internal/config/bindings_test.go create mode 100644 internal/config/clipboard_test.go create mode 100644 internal/config/codec.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/defaults.go create mode 100644 internal/config/hardware_test.go create mode 100644 internal/config/legacy.go create mode 100644 internal/config/legacy_mapping.go create mode 100644 internal/config/ocr_test.go create mode 100644 internal/config/patch_contract_test.go create mode 100644 internal/config/profile_mapping.go create mode 100644 internal/config/progress_test.go create mode 100644 internal/config/schema.go create mode 100644 internal/config/settings.go create mode 100644 internal/config/settings_copy.go create mode 100644 internal/config/store.go create mode 100644 internal/config/store_test.go create mode 100644 internal/config/testdata/settings-v1.toml create mode 100644 internal/config/testdata/settings-v2.toml create mode 100644 internal/config/testdata/settings-v3.toml create mode 100644 internal/config/text_preferences_test.go create mode 100644 internal/config/validation.go create mode 100644 internal/config/voices.go create mode 100644 internal/config/voices_test.go create mode 100644 internal/config/window_titles_test.go create mode 100644 internal/datetime/datetime.go create mode 100644 internal/input/bindings.go create mode 100644 internal/input/bindings_test.go create mode 100644 internal/input/clipboard_test.go create mode 100644 internal/input/commands.go create mode 100644 internal/input/commands_test.go create mode 100644 internal/input/date_time_test.go create mode 100644 internal/input/document_test.go create mode 100644 internal/input/flat_review_test.go create mode 100644 internal/input/input.go create mode 100644 internal/input/input_test.go create mode 100644 internal/input/intent.go create mode 100644 internal/input/intent_test.go create mode 100644 internal/input/notification_commands_test.go create mode 100644 internal/input/ocr.go create mode 100644 internal/input/ocr_test.go create mode 100644 internal/input/read_all_test.go create mode 100644 internal/input/repeat.go create mode 100644 internal/input/repeat_test.go create mode 100644 internal/input/review_presses.go create mode 100644 internal/input/speech_history_test.go create mode 100644 internal/input/speech_mode_test.go create mode 100644 internal/input/sticky_test.go create mode 100644 internal/input/tentacle_bindings.go create mode 100644 internal/input/tentacle_bindings_test.go create mode 100644 internal/input/version_test.go create mode 100644 internal/interaction/session.go create mode 100644 internal/interaction/session_test.go create mode 100644 internal/lifecycle/lifecycle.go create mode 100644 internal/lifecycle/lifecycle_test.go create mode 100644 internal/logging/async.go create mode 100644 internal/logging/async_test.go create mode 100644 internal/logging/logging.go create mode 100644 internal/logging/logging_test.go create mode 100644 internal/notification/mako.go create mode 100644 internal/notification/mako_test.go create mode 100644 internal/notification/presenter.go create mode 100644 internal/notification/presenter_test.go create mode 100644 internal/notificationui/native.c create mode 100644 internal/notificationui/native.h create mode 100644 internal/notificationui/protocol.go create mode 100644 internal/notificationui/ui.go create mode 100644 internal/nvda2cthulhu/messagepack.go create mode 100644 internal/nvda2cthulhu/messagepack_test.go create mode 100644 internal/nvda2cthulhu/protocol.go create mode 100644 internal/nvda2cthulhu/protocol_test.go create mode 100644 internal/nvda2cthulhu/server.go create mode 100644 internal/nvda2cthulhu/server_test.go create mode 100644 internal/ocr/capture.go create mode 100644 internal/ocr/capture_native_test.go create mode 100644 internal/ocr/capture_test.go create mode 100644 internal/ocr/cursor.go create mode 100644 internal/ocr/cursor_continuous_test.go create mode 100644 internal/ocr/cursor_test.go create mode 100644 internal/ocr/engine.go create mode 100644 internal/ocr/engine_test.go create mode 100644 internal/ocr/model.go create mode 100644 internal/ocr/model_test.go create mode 100644 internal/ocr/native_test.go create mode 100644 internal/ocr/page_test.go create mode 100644 internal/ocr/preprocess.go create mode 100644 internal/ocr/preprocess_test.go create mode 100644 internal/ocr/process.go create mode 100644 internal/ocr/testdata/painted-text.png create mode 100644 internal/ocr/tsv.go create mode 100644 internal/ocr/tsv_test.go create mode 100644 internal/ocrcapture/capture.go create mode 100644 internal/ocrcapture/native.c create mode 100644 internal/ocrcapture/native.h create mode 100644 internal/ocrconfirm/confirm.c create mode 100644 internal/ocrconfirm/confirm.go create mode 100644 internal/parity/parity.go create mode 100644 internal/parity/parity_test.go create mode 100644 internal/platform/session/session.go create mode 100644 internal/platform/session/session_test.go create mode 100644 internal/platform/xorg/applications.go create mode 100644 internal/platform/xorg/clipboard.go create mode 100644 internal/platform/xorg/clipboard_native_test.go create mode 100644 internal/platform/xorg/commands.go create mode 100644 internal/platform/xorg/commands_bindings.go create mode 100644 internal/platform/xorg/commands_chord.go create mode 100644 internal/platform/xorg/commands_foreground.go create mode 100644 internal/platform/xorg/commands_native.c create mode 100644 internal/platform/xorg/commands_native.h create mode 100644 internal/platform/xorg/commands_ocr.go create mode 100644 internal/platform/xorg/commands_owner.go create mode 100644 internal/platform/xorg/commands_owner_loop.go create mode 100644 internal/platform/xorg/commands_security.go create mode 100644 internal/platform/xorg/commands_ui.go create mode 100644 internal/platform/xorg/listener.go create mode 100644 internal/platform/xorg/listener_native_test.go create mode 100644 internal/platform/xorg/locks_native_test.go create mode 100644 internal/platform/xorg/ocr_click.c create mode 100644 internal/platform/xorg/ocr_click.go create mode 100644 internal/platform/xorg/ocr_click_native_test.go create mode 100644 internal/platform/xorg/ocr_native_test.go create mode 100644 internal/platform/xorg/pointer.go create mode 100644 internal/platform/xorg/scroll_lock_native_test.go create mode 100644 internal/platform/xorg/tentacle_native_test.go create mode 100644 internal/platform/xorg/ui_native_test.go create mode 100644 internal/platform/xorg/window_title.c create mode 100644 internal/platform/xorg/window_title.go create mode 100644 internal/platform/xorg/window_title.h create mode 100644 internal/platform/xorg/window_title_native_test.go create mode 100644 internal/platform/xorg/xterm_handoff_native_test.go create mode 100644 internal/preferences/applications.go create mode 100644 internal/preferences/applications_test.go create mode 100644 internal/preferences/bindings.go create mode 100644 internal/preferences/braille.go create mode 100644 internal/preferences/catalog_protocol.go create mode 100644 internal/preferences/catalog_protocol_test.go create mode 100644 internal/preferences/catalog_voices.go create mode 100644 internal/preferences/echo.go create mode 100644 internal/preferences/general.go create mode 100644 internal/preferences/native.c create mode 100644 internal/preferences/native.h create mode 100644 internal/preferences/native_applications.c create mode 100644 internal/preferences/native_applications.h create mode 100644 internal/preferences/native_bindings.c create mode 100644 internal/preferences/native_internal.h create mode 100644 internal/preferences/native_reading.c create mode 100644 internal/preferences/native_tentacles.c create mode 100644 internal/preferences/native_text_attributes.c create mode 100644 internal/preferences/native_widgets.c create mode 100644 internal/preferences/ocr.go create mode 100644 internal/preferences/preferences.ui create mode 100644 internal/preferences/protocol.go create mode 100644 internal/preferences/protocol_test.go create mode 100644 internal/preferences/reading.go create mode 100644 internal/preferences/session.go create mode 100644 internal/preferences/sound.go create mode 100644 internal/preferences/speech.go create mode 100644 internal/preferences/tentacles.go create mode 100644 internal/preferences/tentacles_test.go create mode 100644 internal/preferences/text_attributes.go create mode 100644 internal/preferences/ui.go create mode 100644 internal/preferences/ui_values.go create mode 100644 internal/preferences/values.go create mode 100644 internal/preferences/widgets.go create mode 100644 internal/presentation/control_values.go create mode 100644 internal/presentation/control_values_test.go create mode 100644 internal/presentation/document_line.go create mode 100644 internal/presentation/document_line_test.go create mode 100644 internal/presentation/flat_review.go create mode 100644 internal/presentation/flat_review_test.go create mode 100644 internal/presentation/position_sound_test.go create mode 100644 internal/presentation/presentation.go create mode 100644 internal/presentation/presentation_test.go create mode 100644 internal/presentation/reading.go create mode 100644 internal/presentation/reading_test.go create mode 100644 internal/presentation/sound_cues.go create mode 100644 internal/presentation/sound_cues_test.go create mode 100644 internal/presentation/speech.go create mode 100644 internal/presentation/typing_echo.go create mode 100644 internal/presentation/typing_echo_test.go create mode 100644 internal/remote/endpoint.go create mode 100644 internal/remote/endpoint_test.go create mode 100644 internal/remote/introspection.go create mode 100644 internal/remote/modules.go create mode 100644 internal/remote/modules_test.go create mode 100644 internal/remote/registrations.go create mode 100644 internal/remote/service.go create mode 100644 internal/remote/service_test.go create mode 100644 internal/remote/wine_review.go create mode 100644 internal/remote/wine_review_test.go create mode 100644 internal/review/cursor.go create mode 100644 internal/review/cursor_test.go create mode 100644 internal/review/document.go create mode 100644 internal/review/document_activation_test.go create mode 100644 internal/review/document_braille.go create mode 100644 internal/review/document_braille_routing.go create mode 100644 internal/review/document_braille_routing_test.go create mode 100644 internal/review/document_braille_test.go create mode 100644 internal/review/document_caret.go create mode 100644 internal/review/document_caret_glyph_test.go create mode 100644 internal/review/document_caret_line.go create mode 100644 internal/review/document_caret_nodes.go create mode 100644 internal/review/document_caret_test.go create mode 100644 internal/review/document_caret_text.go create mode 100644 internal/review/document_line_worker.go create mode 100644 internal/review/document_mode.go create mode 100644 internal/review/document_mode_test.go create mode 100644 internal/review/document_native_caret.go create mode 100644 internal/review/document_native_caret_test.go create mode 100644 internal/review/document_requests.go create mode 100644 internal/review/document_table.go create mode 100644 internal/review/document_table_test.go create mode 100644 internal/review/document_test.go create mode 100644 internal/review/document_visual_line.go create mode 100644 internal/review/document_visual_line_native_test.go create mode 100644 internal/review/document_visual_line_test.go create mode 100644 internal/review/document_worker.go create mode 100644 internal/review/document_worker_test.go create mode 100644 internal/review/flat.go create mode 100644 internal/review/flat_layout.go create mode 100644 internal/review/flat_native_test.go create mode 100644 internal/review/flat_pluma_native_test.go create mode 100644 internal/review/flat_test.go create mode 100644 internal/review/flat_worker.go create mode 100644 internal/review/flat_worker_test.go create mode 100644 internal/review/interest.go create mode 100644 internal/review/interest_test.go create mode 100644 internal/review/object_worker.go create mode 100644 internal/review/read_all.go create mode 100644 internal/review/read_all_test.go create mode 100644 internal/review/worker.go create mode 100644 internal/review/worker_contract.go create mode 100644 internal/review/worker_test.go create mode 100644 internal/sessionbus/sessionbus.go create mode 100644 internal/sessionbus/sessionbus_test.go create mode 100644 internal/shortcutui/native.c create mode 100644 internal/shortcutui/ui.go create mode 100644 internal/shortcutui/ui_native_test.go create mode 100644 internal/sound/indentation.go create mode 100644 internal/sound/indentation_test.go create mode 100644 internal/sound/player.go create mode 100644 internal/sound/position.go create mode 100644 internal/sound/position_test.go create mode 100644 internal/sound/progress.go create mode 100644 internal/sound/progress_test.go create mode 100644 internal/sound/service.go create mode 100644 internal/sound/service_test.go create mode 100644 internal/sound/speech_beep.go create mode 100644 internal/sound/speech_beep_test.go create mode 100644 internal/sound/tone.go create mode 100644 internal/sound/tone_test.go create mode 100644 internal/speech/address.go create mode 100644 internal/speech/address_test.go create mode 100644 internal/speech/autospawn.go create mode 100644 internal/speech/autospawn_test.go create mode 100644 internal/speech/catalog.go create mode 100644 internal/speech/catalog_test.go create mode 100644 internal/speech/hardware.go create mode 100644 internal/speech/hardware_test.go create mode 100644 internal/speech/locale_voices.go create mode 100644 internal/speech/read_all.go create mode 100644 internal/speech/read_all_test.go create mode 100644 internal/speech/serial_native.go create mode 100644 internal/speech/serial_native_test.go create mode 100644 internal/speech/serial_unavailable.go create mode 100644 internal/speech/settings_test.go create mode 100644 internal/speech/speech.go create mode 100644 internal/speech/speech_test.go create mode 100644 internal/speechhistoryui/native.c create mode 100644 internal/speechhistoryui/native.h create mode 100644 internal/speechhistoryui/ui.go create mode 100644 internal/tentacle/bindings.go create mode 100644 internal/tentacle/containment.go create mode 100644 internal/tentacle/containment_test.go create mode 100644 internal/tentacle/delegation.go create mode 100644 internal/tentacle/delegation_test.go create mode 100644 internal/tentacle/install.go create mode 100644 internal/tentacle/install_test.go create mode 100644 internal/tentacle/launch.go create mode 100644 internal/tentacle/load_test.go create mode 100644 internal/tentacle/local.go create mode 100644 internal/tentacle/local_test.go create mode 100644 internal/tentacle/management.go create mode 100644 internal/tentacle/manager.go create mode 100644 internal/tentacle/process.go create mode 100644 internal/tentacle/protocol.go create mode 100644 internal/tentacle/protocol_test.go create mode 100644 internal/tentacle/registry.go create mode 100644 internal/tentacle/registry_test.go create mode 100644 internal/tentacle/repository.go create mode 100644 internal/tentacle/repository_test.go create mode 100644 internal/tentacle/sdk_test.go create mode 100644 internal/tentacle/session.go create mode 100644 internal/tentacle/session_test.go create mode 100644 internal/tentacle/testdata/peer.py create mode 100644 internal/tentacle/testdata/resource_peer.py create mode 100644 internal/tentacle/testdata/sdk_peer.py create mode 100644 internal/tentaclewire/interaction.go create mode 100644 internal/tentaclewire/interaction_test.go create mode 100644 internal/tentaclewire/presentation.go create mode 100644 internal/tentaclewire/presentation_test.go create mode 100644 internal/tentaclewire/wire.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go create mode 100644 internal/webscripts/registry.go create mode 100644 internal/webscripts/steam.go create mode 100644 internal/webscripts/steam_test.go create mode 100644 internal/wine/manager.go create mode 100644 internal/wine/manager_test.go create mode 100644 internal/wine/review_client.go create mode 100644 internal/wine/review_protocol.go create mode 100644 internal/wine/review_registry.go create mode 100644 internal/wine/review_registry_test.go create mode 100644 internal/wine/review_snapshot.go create mode 100644 internal/wine/review_snapshot_test.go create mode 100644 sdk/go/tentacle/tentacle.go create mode 100644 sdk/python/cthulhu_tentacle/__init__.py create mode 100644 testdata/parity-ledger.csv create mode 100644 tests/browser-catalog.html create mode 100644 tests/browser-fragments.html create mode 100644 tests/browser-lines.html create mode 100644 tests/browser-linked-headings.html create mode 100644 tests/browser-named-links.html create mode 100644 tests/browser-next.html create mode 100644 tests/browser-positions.html create mode 100644 tests/browser.html create mode 100644 tests/firefox-baseline.html create mode 100644 tests/gtk_alert.py create mode 100644 tests/gtk_progress.py create mode 100644 tests/gtk_radio_group.py create mode 100644 tests/gtk_selection.py create mode 100644 tests/gtk_table.py create mode 100644 tests/gtk_tree.py create mode 100644 tests/gtk_web_notification.py create mode 100644 tests/native_alert.py create mode 100644 tests/native_atspi.py create mode 100644 tests/native_browser.py create mode 100644 tests/native_clipboard_preferences.py create mode 100644 tests/native_firefox.py create mode 100644 tests/native_internal_ui.py create mode 100755 tests/native_notifications.py create mode 100644 tests/native_ownership.py create mode 100644 tests/native_preferences.py create mode 100644 tests/native_progress.py create mode 100644 tests/native_speech_history.py create mode 100644 tests/native_tentacle_settings.py create mode 100644 tests/native_webscripts.py create mode 100644 tests/native_x11.py create mode 100644 tests/ocr_capture_fixture.py create mode 100644 tests/ocr_click_fixture.py create mode 100644 tests/ocr_geometry_fixture.py create mode 100644 tests/ocr_review_fixture.py create mode 100644 tests/ocr_session_fixture.py create mode 100644 tests/run-delegated-reader.sh create mode 100644 tests/steam_big_picture_log.py create mode 100644 tests/text-preferences.html create mode 100644 tests/vte_terminal.py create mode 100644 tests/window_title_fixture.py create mode 100644 tests/wine_review_x11.py create mode 100644 tests/x11_focus_child.py create mode 100644 tests/xorg_suspension_probe.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d8d052 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.cache/ +build/ +dist/ +coverage.out +/cthulhu +/cthulhu-braille +/parityledger +__pycache__/ +*.py[cod] +.aider.tags.cache.*/ +.pytest_cache/ +/.codex +/distro-packages/**/*.log diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..8b41010 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,5 @@ +Cthulhu authors: + +Storm Dragon +Angus Kola +Deedra Waters diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6cdd22 --- /dev/null +++ b/LICENSE @@ -0,0 +1,232 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS + +0. Definitions. + +“This License” refers to version 3 of the GNU General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based on the Program. + +To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. + +A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/assets/sounds/default/autocomplete.wav b/assets/sounds/default/autocomplete.wav new file mode 100644 index 0000000000000000000000000000000000000000..61d70df09406bc29cf68f3e32ca57ba3a7af77d7 GIT binary patch literal 9644 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 QUs`{(!1EwG{_Xe&09LoQTL1t6 literal 0 HcmV?d00001 diff --git a/assets/sounds/default/browse_mode.wav b/assets/sounds/default/browse_mode.wav new file mode 100644 index 0000000000000000000000000000000000000000..c26e05113e37aa82db76f004b8113c9c9111b115 GIT binary patch literal 264644 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 {9pQCgh7gdnei_} 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/default/button.wav b/assets/sounds/default/button.wav new file mode 100644 index 0000000000000000000000000000000000000000..0bf85234ed5797985a4ec949559fc02df7dbf435 GIT binary patch literal 38444 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/default/checkbox_checked.wav b/assets/sounds/default/checkbox_checked.wav new file mode 100644 index 0000000000000000000000000000000000000000..a2a5cea202674453c9696322fea703450b66e033 GIT binary patch literal 33908 zcmWIYbaTsTVPFV%40BD(Em06)U|?WmU}9+4&A`C$j){SVL4YA8u_V!>g@GY~?Y@vU zN4nS)_5k6{98*QjIP663u*ZmeVY@5*mi3p=99BW$+bpJnt644y@UhtN=QFS8y~T8p z=LusBw`*t`DND+?HY)+@D3ybN7la;Vu%{#lt`z_zt5qJsh>g^B0p;WU;19@ zAL~1tzkY94{5kQ;@{i-ogTF66xApmCWivITX?uQ>`550a1J~jMn`{MXNeSBT} zdCwchFFtQqe0l$N@z(?I_`WlJ`1Hf(lj-mJuaEzJ|9Oz%6;w&y~mpIrt!gzL&vZ!yo7G-mOTTFfFMt-vZE6U+Kb#)$Q_>>8Fl zIRTbWa$A_E$?s!sQBY>Np}2viT-k%wMwNw)MO};iy2d+>TUw^vJi5zx6Z9YPA2Zw~ zC~6`n{K}MF_B~o1&jTp0EC{_oU#P(?jKNQTN||OTD-7hvB^yzijV?|B<<; z@wfTzgFlXUR{w6j#rA9Kjiw*s*C&2|an<*`)76~s->%I3&VF_G_t{rJe_wko|A)y9 z-k;ZQPWl;n``gb`cl>_w-8=eA_kPuH+XoqcW21TyXSk*jXS3Es4rA>P>ZULZpL3;!vZGknJ-8F+)l z4sulsrE(T{*%LhJsxlU8}{hzUxh~k z|E4@j`se+)``^aLLH|M@=l{L=@cM79`)_{ky#3+(pX(pK=3X)Q(tT0N4_wh-}5#5yxX@W=kva`o-h2ydp_-3?m3HZyU*VFYH&8>>&G)IzPvf3 z`X%J7-j~#KC%>>=*zi^2Qs1}SD~8|KUz_v&z>Qbmcil4mvFXl=AJ6ZF{!D#n_3Q5A zpx^S(QvPti(D*y~<)XijUj6(l^v3X?`CH?ES?_NAO?dzP&x-dozZKt4`FZQ@>hE%I zgua!%y7eXUCF2+FmsOvqy;S-9;T6kg?zh&TcfUXR`P8R{UxdGX{5tO^&yRV3KmXEV zviQf%?#cL;`ylfyfo@hN(fe%Pl3X0?WL-IBmG*E&s}=CbXtD6|>AvQBt9O`hv3?V8 zkbWt*jh+VQIUOtZUD{l1E!s0#*>%>j9?%hFi_mRio2Pr4EnfE%+i@Kpc2yl7_Kn(H z>|3?N*^_jfIF9I^wj~yUHl%wa_jRI#)a=E{QLNd_4oFtC%y|lu>9I^E9&!vtDiomUA+0> z^Vv7=xlTWOmwIB&+iyn~z4?2%;?12y6W+c!wCug`5#CSNkIH<>Jf8kl_Qaj9$BwW0 zy6%|n*UY2azGNNg`10xS`Y*Cao_|?-p+tIGivPUO<`gAz!W5yw^ z52p|Czt7rV^lsC>>~}Zz-FtU?|JnB&4?2HTJRVExb{7T)dt(i+Off)$#na+{43fRlL2EuJ`Y@TlqhhT=V;ycFFaV z)Vb~N`A$7~n|JK+8<)fUZzdkde{HgF_bbLdjIUUCdB2R`vGRq(_RJSww!M7OwcYpC z)g1+IX6!ou&TY?;4_SMkf0W**|0!Xr^MyMzl5*l{_ekV?03=L5yxPahTkdHMAA zU(uI*|JB}bGMK-A%aHo%C*%IFLCg_9PqWDVRb+d`kj1`~H#t+i#7zN}EmahV*9KS#9^mhF-Qg>DnEiilS-aERIW}J0HCFSurd#lGNts{Zj4=DnS!PzuM_a-wzE)Ekmj->`aSzqb|v6O1sGapsk&GcGjBhy-CR_47* zGnqdsy0YjfUS=^@_`z~eE}JD^#){co@+@PqXg))`puqn%ysQ2yb5;CNVDJ8&%(CrQ zJL93B`Tv@KsQ(uKe*OD}uam#L`V#!H=8N^alx2ak7pl$20Xj{tNYpW-%p?Y{PW@2rN6q*O#ih%;r{3LDF5%S`?LO--(B+i{;kSi zr8gLVuDvSt-Qm)YFV^S2eY$^A;G^r&#P=@_u)Is$d+W`IUCM6`>|}n^yi?{)&(0sO zJ$81zuGkUuddBv1uj{vqzir>~;a$wGqaXbC{Q9_UZ`dcheR`iv_x=Cqvyc0e%>L?6 zUk(I(ZawVrh2xmjSLqX~-|nA$@-6zb;rFOB-@olY)Bf%HndonRXFI-CpA-3>dBOgN z&E<zU#2=Pv^OFuY~>$Np*me~<6sjDLR5W@`W6&Me6k&JxLD#5$kNnN6AV7`q71 z9?k;(>)iUnQ+Qd$m+*B;N$~f`{^mO%AHjD{!IgKJ!fEarc}p%qxz8L)vR~O3${Mm; z%dKWREhozsCojXwps<~Jl|nI7s^TTa03|i1Wy;pf{i?+*oEm+s2HFSM*z_GaT#WU( zW|}APv{;w(HQ9*@v^#tic<-nq$l)w1_`+F4aED8Q;96H6p*Xh+p&za#LjEq_1Ua3a z2%NJI=l^GWocEH=Ql2~;Yo4DrojjU$qP(~5YUk`0mA^54QFz8^BKL=(SXzi-iA45) zZn5zHN+L20lY|#DHi<+sONdQm;THeN(jYdE#b2b2d6vK?#ueNh|3%oQ|DDIc_2=vF zt-s=a6#Z=e>igr}C&lk!AHILR{Z{7Mh*uhz!I1WjD?mD>P^OA#&U*rz&{5tWd>GzMv?S90awD~dR zWXku`Cqloa9#{N2^XRJ2dk@Ebsy?*(qrstFAAcVT{WRyuqffEN?tE%EZuqJ7c+JPH z$HG7UIC}M?_pvvh5>8D1;(F%bw~C7aKMk)l{dsf8|DW}v760>}IWow;bYr;ivg&`v zi`#!MJ>B#>;_;cE7LV9|+CSd)bJ??Pzkj|q`FHbUJp=1^8z$M`wag9wCbDc~+{pTq zMUH(s$4U-M9(AsA{zu$3LUVZ)MW6Hei_hi{kSyUhk|oz+(8<0^zmc6qFPr^?wj=uo&24O7 z)N5ERRd+H+DF!i3m33gcE4`8Vj!YMuwZc5ELREkMR_(>YEQW4k%qE^ zi&Jb*7MzrLYJHORx!rTk~T8p_aheFtOiEh%Q5yYHCo?gy}Jd7#MR_mGeI z>VslN<_8!5SKj~kchWtDKiBUZ{Wa%S+t1tAFMof1Wx`jxOT3?7o?rS=^!(?K?=HxE zzI4U*%ZeM3U)J8d`LXFL`y1vnf1dCk?7P2ar}SNs9c}m0_GCS-IO_Ov?*-L&zi&VN zr1ZS$$D&VF|6c!{!otBhhs#AoR=`3&TQp2_xzu&T4{}@H3_cuDzi9dDqFM9Q`Taeq>|3+ zt@e-Qh=wQYRqYfuKK-@qrwwm&7?}8TahON({Iz<=_uYP);18FH!k0ZFL=?SqMaq0^ zMTC6Igird|3rYCs3rc$z2)y;^=6~bD%(upV8MlrVKWDS42m3xFUbalbV=Vm!Rm?l} z6`0)geldK|j%WCyQOIDdc7Y*SWhcX1C3%JgimCsT6u$hcmw)-sPhRT(b@_z<`xF-a zH&zs8SfwzX;iKFt24fjHhD#Fp|1CtL{<{l3{=Z4!JHu%KGv*-Sc}d$0YF2=9o(7$|`RMMENB7+i^xj>)XW5-+yTtBp*_CupV>jP}g}dt?Uf}Q?(BVJrR$ndJ_aKmf+BbVPSJ+kcG`J*u(x1MnLqHs3nTlvN4 z?-wq=_-1_B=4;3KsL#cxj6d}syY}()5ywyJM<#u`bLiX0`3LrXu-w=1{_t+4_X4{P zz5l=S)CZ&8Yd@y#x%F}Bp3NWM?D_aHXy2?)deDTrou6fRIlo-zDf^PnQ~B*5 z_lcj$+!=qOxupI+;duJ{1zYq_5f;{O3=Drhto%9g`OWt;ZuI}0Gb<5~z*nOGv6CW?Up8S0B{XehkUOxCJ@-6n~3uYCj8o_^Dd2(}w zw6*3+bQnj8<;J#c7_ilw!O$D9`tusBGc4P4SsGi@b@4 zw#*}!ACjWZB9dXw7bHqt^dY_wqfo$>MdfUdnsidIdj|y^~PC z^FrYaH(g8&iVYaoged;xW)-4c)St1>!l&w)UD}+i#Zz4}be3xZ-=a(3Kyq!e@Sd5|01%Kv?S6XTi{)JNT@Be&v?`mB#h;w=UP& ze^a;{nT+{&vM&&8;oq;cT;hU(g2Eqb4;3HB4Angj)+(2+e=CTZ{E?B-xg)8kc2?}3 z!cL*Ll3RJX1$8;zvu$B={geII_oMcYgU>oX72Li2=I7PW7wazEdg^p`+S7HX*E~}_ z{ph*NDd`tVCk&qH9QpgGd;hElMtgW4i0&48_+$5)M^$?np9t=YeX?lJhsTe0ZhZV? z`^U$tx8Hjbx3l?K%bv;?yY@4`(mxpe>cjqZFID$$dogX7<_piAyI#cX41M)%=j+$1 zyDq$0xa;rRm_5!PH1|LGly=DKEAO%R?+Z@m{M>Rz<9FTpiGNOAeEL`A^0a@3muvr7 zUApw|*Tql&&93k>F1umFlyvtPGuuNo)<=)E*tR}9&UWm@K{ltCx@?PI1hVdUeu|~; z`F?URtvref5-e>6;gEP7`m+PGjG;55Otfp7G@F~9)OMG35@Ajm;GK76wO>_gA}4UZM~*FC+s@9nee zdsClx?{R!SbGOv-|63R4qpz{`Pux@K@^V z{+~9luYTit`S9bM#}{51-jaE|=UnZb%|`-nnC$;?EqXu44e29sw;9jmKCrrK_3X~w z$~VWJKKbPSF8jxg@A`kunD#M=@_yzJ5#P)=UtU^dyL!5$ihiCPhpDO3YYSUtZ7UU} zG|RmTq81zF1FQWs3jq#ewUN@-aH zOYSp!B6iw1QpCgXl+Z=S;CS z=Vzj;oNtJJbJi8Da26FdbBqv3v+d-aVX>Es!6cT0*>D5f1pPUzm-V7quInykO3*Q4 zyr%W}|16E#f844Ce~cCCelC*A{`N{V_KUnQ?-y;M9bcabt@`m+r1`gnxZdA|5_|v3 zNdEZiDXI2PT=M)sc1d=|R;fl7Hrea!zH-7`?y`Q|?$Wn-eoBc5aLLJtm80q0zHphIrVyFHQnOt>i@n-pB0zKk>T#fwUEWPZ3|5X2*|Cso7@uyF( zqTdQW`2U>$TF;~0vv=;kJr;8N(P7rx&ku{-sXtnE*YEhXyR(jS-`#(d@6P5!b+-i% zsNR{k-|(LM!TS#u98P=8d+h#`7snl+PCp^{RN};pCzp>Me=_OVjb|H9EO>d~w885o zXFXpZKU@0h*Xg4#?j3*pY~rD3Pb2r;e%iXn>6zhPrsu``J6?Q0*!a5kNZ@w=e_V2^(vkZmzJ~Q2Y(7@vS*p+qf(?6^` zUi@S8dZWxC`$3Jf`m-~q$k)&8)?co%p82?!nf<*K)Au(4jGb@97~9`wF|2ua@1Obm z<$qScbN;pR&C(w#uP%J&d6D#O|I^E#UOjYuGx@g0ldYGt?(RCHcJudf)9e1n1a3?{ zA#i8Mx%@|aujjtJ_;Ag8{nx@@t3T`hn)Z9@|7w=`toD4mJO&acg*p`79j|Hb(s-?PYndMYzNy`)|SIJIxR}#`cld>bU!J4P`@SfRsNN@kVL4EouCiTV=hlN9+vG4 z2mUPlUHJ9bx3_N}e^h0})`P1~~%kQc;27j{N=KfZG%k$&UOYKjxPncdGzO(EJ z*R_%dn=a12xBWur{Y#f$JYv4(`P}#+)0^BEfgda0XMQjEw)1b_-?_|UtUjFayvzA^ zh!zWn$@WTIQE8Q1p|e@V#iUO&!HP}ivF!_;6njnGm-btA*4dxc7P4p6?zM~1lD4bT z?6jSv!EZZXeT9vb+D_}KD%Y(PRRXNDR36w&RH?EPQ%SWypu}STK|#S*Lq5i8vuusU zRT(qOJ2KHW0&+zTr{u3VFHxv36rQ4L0WgjVNN&lAHCDtq9BE%`Q zg|C`t8rOR^3DyS;cm6s3s`!!fspiw7mn?68J(%?T&kd(X4=-llop9Rm=8L1X*RCAQ zx~g>G(v^+-nXh>6@4L*nKjd=MzM{+Gd*@vV+QWWr(;kNFY5R%`^4|V_z3o%~$9vzSzc2VR@$Y=b zV=TYfuJeTOFo{YDxyn{ZOi~q-f2%uH)yd39dxdSezJYUTh+`)bnyVud~}}lXj1zs^z-5B`Yv*9uUryJh zm>h#8UfBK+-D_zt+-~xl-%$SxkEZr6u9q5HI6~EB*HkZ~ z=6@H&ivRctwfy|f)Aj8OyTg|V7KzW(n4W#Q!r1*em~riwV+`qEkNt1_I_Y1>SJ8hL zzHa*${cZLCzVEUOi9dJ#pYluN-;ZBre|Y|!{(bGA&cBDuiY&Le40xvqMvA=1m_WhJ+<}WAQmwEE=2Fv}Nix+P0 zJ1KYd`JopV4(&O8_Tl!PGlg3-&V1cceMWPu{@MFmm!1>Z&T+wNhx5g0JI`FYvwOys z!}~a|A3G>=OY4Ztohe6@?kqT@aC`gy=v&wJ`rK06SAF~Nf$)2Oj`TdddZPF7nNy)p z#7}Q{oO6=(5!122hvJ8KKCC+U=3(f8iH`ygR6Y?nbotr(Bfc-69)JIO>S^b9pU-Xj zzhkLGS?+hO+iCBTk+?r*JTdq_$d_|tE*qNw9{^~=hm-r2{Kyb5oXfr zEob`H=dH;jpJ~Rsyju+ez3TK2dA!j*=k`R$%Jq(RuFDZEMVHH(r<~I@x}7eoaX6}| zl{(x~b#e$)y=A{xrO>`k`G{SDqPA_VT!EFO%uDmXQdZ`_q~=*@$aGrom(91cl-pn* zEGuuHEzNCvN#dvF8PREG$Alt`!vrQ7nDE`#y}W2yc zB|g^vm;c!O&*|gyKl?vs{GRtI{r9EMm;apoy5^tGk4OJc|5Ri6^=lDB;-A@!d;V!N ze`N}1Q|GAXO6OV6Yr+@C$H2?Rvz_x0`%>1)OcNRR|Ec)P^R4Gc{5!8t$`a}PO8<--)c05| z(?09qssG=#)X3IryNQc$uIV|ywIap=BtU!>jZ>Y;Vb z`IE+Hr#~4^zYqb`+l;BWc+w5!1?_Kuj$trTwI?e*dsm)v+#TfX8ifC z`k&{!YroID&Hp*)?aJ?e-tv8KduRAv`2B+KRv!-hc=XZeSN&(U-vVFden0$r^XILv ze%~#>WqrN)?eiCrAG^NE{89O{pYbizPu8vMavYmDjX2yndf4_dE3ydvyT!otqw7!B z$9dnEyz2bu_*m}Mvs)7%n_gzVFMM|St&7L%uX!BUcX`LIpBIj7-EsEN#C08cxo_;l9FW+^^{U>h39+1BE;6UoF z7Y7d9+-FT^7+Flr_Md#IH~gB-0>q1Rvmx- z!1K89!-C@)kItXi^7zFmk!M%VcD`JGQTpxgtI8kxZ%zO7@?P_ooJae=Eq{98r~b}xFXPo?n9F~G`G`O-+ZCZa&Ih7hJmr$F_%mhh3muaY z68R+cSU6k4N?1v(Sjbk?R>(^)=fW36|A-ifrHM@u^A+b2y(0QX z&_ieyZvgL0c18B}%omxaFh6HfWo>1ZXHVvAwOaf^|X%R1u*ms}%ZXI%pk#}~TE_IGuj*oo;T z+85~Vw{O;+Y44@WXunE_-!4q2$TnLi$ab#wR+|v5H`YHiKUz=HyllN%gWY+z46Zccf0>}JNpt+$pul)rP~vHAVgFWR4me>nW6_WPF)6MoP5Wc}yc zCzW3lKF<68{C(z^k8i`@3%>gFO6!@!^9hgcKa{x3bocc2_FLgs4_#NhGU-a^y*iHzbbNlbxk`&sQdbU7Aqv~pbJInBLCa1Fn#NRz;Ju^7P) zi3P$Dk~c(EWtb&C$T3JglY1d`SJp#nhs;`u|I*)h-+>!iSIk|bxv+;8mv0mYgXOHGj=e)>U&uPm4nQN6$DDMT~`+WAI zCV~$oRtg`H$rTNha~7W`w@`A1{88y^a!;f!$$&%W=sv$b6PwDib5)D6?O> zTPjQHzf`6auWY;YGdVWd4mnlX0O_030^)H}+XR1$UEn<i1E(nmH$HCZ~A%n<>RmGpWpkk`Q`C1?yqls{rCLB*QjT8Um0F{ zeouZ@^G)T^%g?FLM89dg5&tFk=IpN@Z{&afdOzd0+FQXNYR^x6`u^n9yZMh#z4Ct$ z@nY*8^XCG$3Z6FJD0{m0`toPDZsb2be{;dZ+ACSN{7&Yc4?J+~__O`3C)1C1pDR9d z?Q+cJRkurSetp>TZ04)#nY(nv+q8+FG3*ibk?(5}yTA_$G6*vp!(h@OL#s z%wHc4UnX_I#hgpU9`n148w(wj{wDTWaiw&l@?t4|wPR9S)bGkjsJ@b^S8bP{s{L7Q zfdQ9}qVY`KR`chEG3Ja$iaKR#%u2Gd*3#F+_j3rdF~13TyZ3^^t-HroU$8lvdveaw zdFy%_ANDge+-zlNh-$yrb$p7(eD7r^m+f6|v8ru{)8^xQ)%PSG*tq}nvE<`Q7ZzNd zcJt$%XAh=6w|TSh+vo2KejNR6@Uj2L+86fk_dGiEg8Ko_^ZobwA64A_d7ty<+6Tf{ za&PNgdU5IZ`TC2S&m~;pynN}3%ca7L6-RVVR&6)lm$dWJo`XlzPFkKxKF@Hyh@;Y}Bh zNzB_9+mv-P>Ur$;pbnoq&S`cN&E{Dg*K5&fQL>RfEc#BsoUf42k1J46nP;`IG(WRQ zwt%Hri|`DgiTuUfi`lb9sswqJ*DG96HBeAfeJI1Bx=KDk>z^8f7Sl*t2y^lpPphX zWjD7{F`1sQKG-k+cl@k$r`Tr+`=a({eo5fZd6CGPv@mL2Y)a_H)cxVYnSGIo@zaB+ zgue6s>)zs^Zdq6k!#Or6Ozn5*0z9@M> zf?a!uYM9Y|ts6!qT1yNHHN*_GwbnWKTFr})@ck8g(Z9;w#IacOiJpy8n|g{Vll~Uh zQVTBUP3G$iZ)&Vzkz%Smp?LAcirj6wEB0-w*ub&<)RqrB>Q-s4mz=|~bn>-o=N(k2r5f-T8m* z{Plz9mS3K7>DrB+XB|&#o?m>-cCF`z`$5H&yW)LR^+d|*$y*8* z&Ro~I^~jzlr#GFdzubHwgl>7VJ$0+zo`j8IJEL|T*|*_f;qg0% z{LgGTW_LmCe9euQw|_lZ{apR6 zFMjTKt^029nP*o{pNhNGdiK<1k&8LkcAwR|T(M{Nk-Bx~cKU8!y)W$8>oZa}Z`|~_ z{^f?&A(2y_8~yfn>_2yE+xz;j&jsuS6NG*Wyi=}I*y-}acDIj@tBC6shvlw^TvmIn z^_b@)@AluN+0NIL*Z8Xbd)-6EoAgT!HFP&?E>rm=KUeCg$YG(sT&sC(IOcE`2+ZVb zkX#}vCizhGqOg~cmjJWCYoX0TVe*c$_q1Bn;b$mR`Z2v!f4gI|3(&8JF*MHtU zd9DAB6FZ;mE81SYGjxOVmg}4NcYWLCe{k1+!@~-P9v`hdW^`uuIpr&nH&5M-y1(Pe z$)~cfcYiqisr+O4N3rKOUwnV4{A}{GBd>nGa(VauP40)p*IVBU-#_={-X-B%ITzpG zV80oBH{tPvM{T!6?nLYhIo`W|(Jslo(T6#&2w!cwX>)7(`Q|GPXGE^7zkK<|;#>3Y z#y4J|Q&sJhG!$k^y_c{Ss1&H=o6656sv){Y{H<85u&S_<&=Wx}!Ab!o zVQ-=B$_)x>`qwn)>oBXgt0$|}tL#!)sl%@`-)@WLId?V33y$w?s?C-eTWN=BUR1GB znyzN7d|RzvRZKHaU0Xd`@iF%cc7d0IZ~ZUy-fY^t@YtqpQu`+D{C(ii?#+i5>`p(R zySsh=@*TVO&)F4yc>M{9^Y1S$@Z8z85%6e4zl;`E$_e}36zhCvf{*l>p zrw4gYBVS9r`}9#1|Kc&X&ZmOBBrWbdin_cX3g3s&F}OpY5(`Z|1tn_MYK9{nyI5s-jvdT0FWBHPsar zWFLtn3$Y6*@GlVL5R{Y<6rUhDP2#q&fpC;?k&vrIf;gY-8tF#W3Ppb1cWO)Z=4tNL z|D^L@uTuB6uCUGx;}ZrB)_2X0*i1B2u*fldrl+p`SnZ=nq8Z=o!GbVu;Fp5U8?)`u-+|N`+egn| zy{LaY`|0i5`|dxuB7f`RmCjq)S3_=Hyu9RE+6A3U$|uxLm+V(QQoQfW;lsyc&+NZ; z;l__UFYXCnKYaV;HJ01I9vD8n@Y3X^$ye!bWjs7Qy_(IcGIo*{0Z!j+ZrSBnnK|*< zs`&hO+3D}=e$2(euHWRhVUx}S?LFExS|@evwWe!Cs7lGmNq!To=a1vI;EHE2X7A+Z z=PQ(u6xA0wE?C9Cmd{t{h~O@95wT)LKiU7f>grPr%eAIxrK!zSwN+`;($e5Fb2A9E zH8WGNk}+9m+^Tm|zgGLUwywr9RemKw`TH_y5?dwQghGYxaJ}FV`Offt!L62i6Hj`c z6FKC6Jakv$L6L1I_I%&6Ygg!wfPJ2aDv$S`+I3Frg5wpDYyCHWUVnaT`OU1m-48!M z<$Lq!-IuTTzefG?|M~k9>zCA*?Qc|HiM{20pZX#EozFY_=P#eL-d4TOd%@uPhjX1* z)vrX|FuWReqwL(-OVf^SJN@Wj=!wW9PfjYHT5+!YeBEWK%M-6JxL$lG<#z1-Blj~N zDZgrYv-pSaZ+Greo~H_D2W=DK?xgL}{kasNp z^wJBT&W2rbIvaOU=NkL16?acRymwvm9>*1jt+YuYSJa zdg;Ygqx0`C|3827isqG<*DhRNa5L?;!o9BhlOC;o{Ph|0YpwU3KNtUIWjo3GUO-MD zT=k~%8_U~fzwC@{_E<++id$5h>^G{^chs%d*{Q~(?jRp3r!MzQx>9kKoUFnn83U>R zV!wra1ey7mdDrlLxgyRNd8K}B!6H2t=tazQt4AN)5OBWqlK3W ze&qJ$;`%?Ef&Kl-Pjz=V9!8xHxpM0y)A{tHQ%;{f9(ZojMZRlhS3_@fU2VE~_~wIq zi*8?kc(vhqJX?1+^YN`)%O1$x{`J7>p6-)d_lut1y65`1@3zN%zMEI>B-~=Tcj%7Z z1F`$39yUHndwT!X*;lG>nBP^rZTjf>qxV-3doJr1kzxTIB@wwB+7mRK4YPG)OjQh4 z812_>*WRgqOS4+FNb8A8x5fmeZK_-3?G?(d*;ESLwPcHvgPB{S% zu06utJOvVy1uUiJ3*C|A6<#6TB^o5}C26FzRk~IAx6FQ}Z8BRFw@Tkr6p^{1=puVd zX`Nh+s-wbrRa1q1N*;2H<*rL-N+^oA3+V{<2(j@m7D(YqWno~R|K-;=%V)n{Y2C`c z|KxJi*$|dWIKd&ZStGjjO%7j~`SC`+mymj!t%su5N4WUNq$z4kd9JFmM)N;ExBKk zM_f)+RoFo=fPXpv1l}y3^ITt8=Ca)Vx$Jku>o4z*-m`jYc=O7Am+L$3dfd>smwqSZ z;n6!Sk5=D0@^JfIrzc^LAHQIE{_xGK7ZLB5yyW;0`P%to>bpaqSH742TK?|Dmq%|N ze*XT7?~~Pw_V>G=S-fL-?)GllvxjddKe2t|^62%;{SQ=LFh3M|@%)k6%Po&{Up{>F z_r>8yXI@NsT>E13ljau_pIvx)>eb#i?eFtHT>BvY>C^i&ANfBwd=dWR{YQ#Jjcu*y zE`fN(rBZ)X_sGksUY0+i^i6h!;#HXs%Hndas_W(UDV~xRm+ck*B`G4(AbwG>PjtNi zr^s~vR-w1N?*)(WEEc-UGgbH$?;8;Z{ug3r1-^(+6XcS3FSt^i?q%i4P(hMWzTf3tboR7cAx1=byx@ z$`!!r$+(m8)|b`a&p&s0TXFyL(CuFH&mV8P_v>-oUH8Yg@A^FHe7NO# z|I@P93NLuy)x9)&zv$J^_fy{N_!#@H@U#4TlP~w)34e)td*svIS4|%(UL?Ld_I&l* zn&;_nJD!TXS@d}7tL2Y0U#@?6@cD&@x1Z-fj(aKh7`7A!XD|f zaE0MMQ8p7Xr*9g|~%o8-@(Gj-h zy(-enmnbGA@LYVBV2XsC&<%+~p(Lr5!pEh5h*Zfu6`d$!BX&u8ooJPGmgr0A|Dv0u zeZ;;?*@|^a#fv?c3=n%Nt}Lo5k}c#c;KVx$Pn#d^dN%!mgZk;HMR@ia%a_S^Od4<)!zGFH7H-KR16D_tfvrk|!>&|2>g; zo$}P+b;47j*YBT9e)Zu=(5tIY_q`H&p7&bn#pBl!FMZzlzP|SM)Vt*$0zO{LPDrftWJPWREa-Wcn$AC!7v^U!6n?+1g&@`3UB6pB=VJSt>`6wW3e)U*J93s zr4oCD_@!(_wn)7cDU*6Ea$R!1$ZN^hA}gg>J&JY#(Q;L(%kZy&yWcI@HSXH^gT zpBX+}@$BBiJAco^q509P#Diyp>CcYVMpHiB0hYRL>>6Ai7w^eC3=WIOsqlRxcFy5K1oO6bjd@)Z4yqx zP2wAbE{dB9cS>-Hv`To2a7b8)oDf?g{6%D{kfLymAcx=%eph}gz8k#Xc(S;7xo$Ey zvxNQj`Mc)R=dbr(>wFM>@&3)KXRBY|fBNcG^OHj_%^qKV5&YQg$%$7YcDRo-SyJz-SU@z-WI%^_%`UJ{@aBw1>Xw1zWny{8?N`K z-X(un^x@gZ-yg+3_k6DTGX49P?@#_||69ktoYjS2mWM;cKp;!ZL+Fh71Yuvvk0R#M zU1CWx-r`rJ3&eD#mWb?^P!_%~ZYk6*_CsKkhzEbP@K4^QLbkk9gp_$rgxB(Fi1hHO ziB|Hj5#<%=7u6KFA-YiDyV!9-28laD4HEUjpT#|di^T$jc8Gow^xO{Ma zmGRE^#jH0D&yK#j@nq@Ct|!Z1nmnEHD)`y6*Ba07zj^R{)mzt>|KIVvUio3d8_7=# z-im#m_g3n2=NtV`Rj=(n-hH*?!_HSpA1=JI|B&%&!~6L!uf2Qvg6-X;7oBhIU*3FM z@@mezXRl7Zt9s@3?)J-`x79D-y>WPD@Ydr^?FBEq*`*d$QOxM!pFr|37ryK zD##`FP+*tH3VwTG4?cY%FWxnRS9rb&O7Kn>TF)yetj||2yo&Fjur$Ab$PE59A^`%m zq6UKcV!s8w#QcSlMb8PEim(eF6TT?GBBCj{SmcQyt7yHDyojg}ztA#)_53&Zw(z>} zF5yYw+0EU?eS+f|#{;JQ%u9aP{k8r2@B73Le?K?8_5YatI`jRim*?O5zF7KZ!*ka+ zXP+;4v--unx946SeOLXe^1bG3jSqfrtUso`b^c`h?*FI7@8UjBe&_c&;2r;Gfp;fA zU4C2gY0F#nPX%wce7yOF>7(u&yAMlV_rE{-I`#eLH;>*ozYY6v<8Ao|j(4>mKE1X1 zaR2R#_bc8xe@OUH_R;IphfnK13w){kviHmRuP?tk{Cx7`&%f(`yIC8U?YVO}LioRP z^9v>NnTiAo2#S3ZG!}m%R4@Kh$V$9Ys8p<1uvWBFV4BDx{$kL4qt(=Du1{LzrZ|^8UcP0JAv;){rq-9 zn*6pxVf?v5oB3OXI0S5jgas4?v-xxQjri8_-r&*Una};4tB30y$7POZEMctd|2-K5 zf9n12`4aSf@y82a!r!0yr1tjChn24~IldG7yy0E_=jZQMf8O%0_;b!X`OmrUet#-|cj=S%yQojCZ>N1c{AS+= z<2M2y6y6+rzw*tW_o;8=KkRs0^kM&7uMcnD+J4Y`Xa1r2o%M(C_v=5b|FGnv+^3_T z@;=}B{N;=8*KOaYe2@P#>$fOVGeZnJ4{I)WFGmEQ1ospHVcsC2Ieh8Djr^=4&-hzJ zBm`E8{Nnc&spLN<{D{w3*n_W5Xfp2=!6!U%f~h=4g8Dp#g7G|01rPIx2z}>a5n|$% z6SC&*5<19hC)~pKOxTuRRAd_e9^oc_24Q9X5TVO_ib9Y1)P2!GA_q5QS?2fa7UAK$*&_Hp6ckWaDiK7ac9uHe8_nJ`on?`=Rdyrc=?m!=WCzKzp#B>`|a1aq+e#gr2qT< zJI$QHbejDR>kY0t4p!bouA_YKxSa)Tc{2oc`J9DL@f{Ij=RYNMmoHzagzv53N8WV7 z4BiIc$Y^}ke#9pVQwgCILoAwrCb(&TDM$+zU94@Gx?z z@_BRD^5^iB2x#!W6=>vT7wqM|ClJfqFTlgAC$O032)_|eDgRUMRQ?0pUHrSa5AZ+W ze$6k=!zbXvqbrcj6DTl&XQjYH9%aE)-Uz|{ypDp`c{v0x@y-)i&Z{Jl$$OkXgtvh| zme-TNfR~rQf;Ws$o>!39hvz7FDEBh1bgl@_P|i&DQ|whNfvmF`o-wZalliau=Z4=Z z-}n72`0D;c{)@=>JD+}ko%8X}mpLDdzWjZ^?(@F)&7Uv7U-g;q!<)|@AJo6peTe!p z??d^QrVrU)v_EKkx%2+==e_Uee_r>#?(_8bA)ia%t9&+mfBn-=}pp+}r1Hi7i`3V)ukzoPeq;PD^F!rF z$gej)oBl5S6UoTJ@RvoM`8sorbmc3bWujtZVO&V#(Oxqk9Z=f2GE%wsFi&66rH znI}r1j7LI1m*)!qWA1JIySP{J&*R?4KZE-j|6Xo>ffw9-0$;iR^Z(^$7Lef46!7FJ z6IjYKP2eq$pTGm2$NZal>iD~OIQWBk`uQYyO!@wBKjMAC-N<{4o0YeRTbAb?*D0>o zoXwoiI6^r-upeSu#g@#nmBpO#H{-&;H~(e+68aO;hr ztPjCo@;+F8N&X=A#r?zM&uSkQe>VP5{ki2s&*#@4Hhp&ec=q#_k4#@wK3RMz{p9`S z_^0GAs-JtmtonT8%girLU!Q#K`KI-K*7wvOQ-8?+`tft~AN4;k{(t=^%e(eTe!tJU-C$E{o(z`wTo{GwhYowf0x&pZGuT{w@5sl~J5AoF##IHd_#@3&$b07|t#1!CWmI3EU~1 znLM^!VZ492-t%U2Yw$I5>+qFubMZNGzvC6>{?9AQZNz8DoxvB$-N2W{-Ncu{-N9GF zJ(q7L_d&i>+zR4GW_ZOg_ix)jwcn9{lzz_qwdH%< z&sE<_f6V>b@V)!XjBh=k&wpL{N%X7Cr)6JWew_5>*2mdju76zd4KllGJ z{3~H}VPIxyWO8E5VPWQY%j&|(%I?GUhCPM*Hb)iDVa_z(7A^%oDehT(72Fs3W^zB| zo56jHuZ8<8UpDt`zI^WQeD&Oa_-eSn@|AOcBkMh;80J2PXhyDoivO4XX8F72XUK2YAELiBzl;4;{-*Oo?5pc{nJ*RJd_Q0O zy6lttSIbY|zVLo}_J!%w+b`^&etzNl#QOE?N2aegK7Rjl_T$Gd=RUsta`NNNF9$xJ z{Id1qhA(SAPWrO*W9OG=A18mY`n37W)=!_lSbjGDy6bc8SBo!4zRvmb{p;^9n%^dT z?f#bdP4>I{_v7C!esuou`C0sP!LJ#=O#XcTE%k5q-&}?z|6ej)WK>|?$9#j$iB*y# zgH4MwiQR(BkHeB%lT()G6X$K7XO6SHd00*T@~pHF>oaa)eE4tk|H*$g z|DE{j`){V7^M76aKI!MVZwr5%`+D^Iy)R7P1;0dmOZa^B>!nYTU)w(Ed@cT@{k8Cu z;n(a>246!z$$T~V^#6MgG&$FRGvVzqoyx@ula} z?k|5oefiS*+2Sk9m!hxLU)Fqm{^jLY&#&s=u6*tKHuIb4_p0yPzGwU>{89AN`{%A- zF~3azEcyN9ul(Pi|3Cc;VEW5Y&2o>)ku8H|Hv1gbRU9+f7I4PUl;l~? zxspenOPsfk%ZGOfR|M}St_a@UTxq;#xO#a%aBbw}=ibjN!M%r9f_p8m4EH=*4NXYy1#DvRQ`4Lr`)e| zJ{5i4^r`mi$xroP&wtAMdh}D^*8`vIzTW=i{#E94{MY)=Enk0qKJvBfi@>+1UqZh5 zeVy{{(AWFlLcY0w7yN$p`?v31Ki>X`{Q2sq<}b0|lE3@^MEo)TckZt!L-Bu0CO^h- z7G-8pwv{Yt>{nPzInJ`>a&BQy;_BuI<__Xi;`zyWfTy0ziuWB?1Ft&wJYEy-ExZog zdwG4iPw^&lKjE$Be$HFTeStTPdn<1Q_e@@2?hf8y?pEGZ?lRsKZURit`(%8>b`ZK8`#Nd5(JaWcCKOUbY(6ZLD4_yIIaKl{1Sn zerMGE@5-S0x8a}H?+1Sl{cQa`@kj2j8Q+_JZvD3P$Azz7zJK^)_TBu;{M7jM5;&Y9zpnaZ`?c?r+SiUx3SZ}Z()_yhlkV3epESRo_$2@J&?la+ z=RUE2<@v1kHUD$O*C(H+evSTe^XthkvfoU;CVyM?b<;QNZ(`p+e4F|G;CHJZtAG6X zvEt|HpL>3-`^EC-!0#D4@`6=TAmP}>~wuLNV?2B0AIJ(#ZIb+$4 zxKufqxSw)t=kDNi=K00Bl*gXyE>9ZQPo4%Y7T&2`yu3@flzDe@neZOqQsrIE#l<^= z>m$!ZuKPT_Tn~7ra^2&Z&b5=Lnk$#bmy3Z%h^v?9M!k@iXJq|Ah>^|DOI!`;+ze<1ev4yMO-v zwd04tue0B+e}4Q{{6qQMw(rSbxxVlDQuodBi}<%EpKpFW@Ol5&?Vq=PUG;hK*Y?ki zU*kTfB*Uw`sdT{?SJL}2K{gR z$HmCRu!E_Q(T?RYQy;51%W^hZ)_Lq~Y%LrQ*g`oMvrBVDa9rW~!I8n8&v}{q0H+Ag zJ5Ey`HZDIN8Lmto6RvKa0Im%@1zbmXnz)YgbaEZynaFjJXD-(Iv;n~WS z&oiA%j3<+8A-4vXHup=;eOwzk{kRG^&vD9dYH-fy2;}(4p2Dunp3j!VR?Iq)HJ4>4 zixcx6=9`SoOs)*_j7$El|1bF0>)+zv?0@5az5H$W^W`u5AB?}kzw7*L`j+wIz}Ma1 z#lGr%@A-1zo6eW&Z@)fAetYoQ`P=Ew8sD~k=KePC^P8_dpRa!H{Cxgv&*zh0r+z;C zb>-(nU-x|8_4VrKZD0R>KJeB0%iXUtz6gKg_!{=D^y`Fg55FG!7XFRx`-^X>-xqxU z^u6-O(jQ4bvwsHvGXLfGoBem@pZC8v|7H2B_uu>9QHIU`Lz$!*PcmmS$+OO54q)5B z63f1j)sLf&O`X$&{RQVA_NiPmI5fD$IA?O_b3Wx>#>vZbfK!R*BBwsjV@?yEPn@u5$esF%}{>6Eb`y=OU?i-wb+^absaiwq;aLI78a6RCd z%(;L=gfoDng5xgx9(E`8XKdYU|5*>Sim?7+k!LYtVP$q_KFDay6v^;|;o83i{}cWO z{$u|m|M%!G=08h+vi@H6L-p6`?~y;*zc2j}_Ko$&rmq#>)xUCm-~VO*x0)}LzD0eh z`R4H@`7l&`wUp&7Be+l`P@g?G0-Ithei@)T3yZNQ> zo5t6J-==@%{m%O>{CnlMso!6IyZJr;yXg<%A6tH0`4Rea!%x9qvwz+E)%kn=@6JC( ze>VQj{QK`;>%U}%egDrhvM}l~J1~{8q%tpL&1G517RB1buEiF}@raF`vzL7%rv`@` z*K&?STs)kT+yR_^+*O?U+;ccPx%Y4`;l9hcpZhQ8d2V5@OWg8Yr?^$Pc5g87CD&UsnvgQ8A`IYN3=TxrgoJw5&oEtc~IPEwOa~$Bv<tHJjdU;cjE`sK{Go-Yf&<$S667V;(Go9~z4 zZ-HNYzD0lW`j-F2=Uc~@pl?gQM14E;CF>je*WPdOUypt}_m%Iv_qX`(C%>)z?(?1T z$Gz_vKRSNA`QiVw_ow z>37esFTVZyvi#fbFNNQFzPNr%{v!9y{R`7Kt1q9wT77x<)#b~_ui;;QeJ%LH@vZ-h zx6H!zh3{w^-be@^S6fY?BDNypZ7iFhv$!bKRABY{k-+l^w+Lm ze||0feeU19rp!JUhWN?C%GCp3%MLPIk-M^Oyr!+!Ov;MQOj|f{VuyN zyB_;~whT5&wh64UtZP^%vm9VPzGvO!WFOFXqf9?OZ?sv=Y8Gl0lO!{l|cm6-6 zfBXL{{eRA&!@$jC%Bak2#-z=n&8*BS!otS(lI1GfYSxMDF>IC`zt~Q3G_!ki{$}69 znarWcwU48Ui=T5JmoMjQu4+ym?)jX`+`BmSxX*GLao^(9=Dx=%#eJLeKi5UhJ6!uY z*K#f4Ea0l)ROWKyJj}_+8O*tk<0eNCM>xj^_T%jJ?5ga)*^1dx*|xCmXMMxM#VXF? z!D7f<#%#$njmeO48KVrtDhB5NtNy?JyYkBjT7JSS3I^o-juY10I|N8w~DC`|NZwz^53Jsn*Xo;b78phKbr9(LpIY%#(d`O zOi3)$nVnfPScKTrS}gxlVIF1Lr!+iGS00W ze>lQ8N;qD#-)Apow_tz8*1+b^wu^Ns>ob-&EUYX_ETYU_%%V&wOl*wBjL-jf z$NEp7TrWGvx%pI%=Ea7a5tRn0eSog8# zvW0W}W_!d@%^u77mi-8)ABPOrB908MR~(DER5{OZ1#>>bgmZ8NUnHJZY~|pHJqWL z@GJLs*Uv4#ZvEi@W$~l@=d$nbf2e;i|FQiW-;c0wo4&JrOZtBBtHSsGuW!HQd_DRt z;_LEn{$D453;5dkE%Iyox3sUF-^#yE_%`k9qHia@9{k4n?ajA{Zz|unf6Mx=_EFeFNc^b#vHZuUAD%xee*XI@@N56C?Z3Kz=lo9jWAMl4FXLb5e-HjT|3CK6lVRik zAjSm@K}_9@p3DVIW-R{9VysFm&sm?bEM=R<8pv+S_L2PrTQi3*I}_(&_Cih#jtiXi z97F90nXN9H-fLvd6M3vOi)gVGCi~!n&FD1q%x+AB#JSCUX_DInyF0 zQ^tLanhX~hr2jwoFZl1%Kf%A8|78BC{WbpW^C#+8-S0_1_x-y0ga4P&kHVj`zd!yV z`aSi>;%`5`JARw>{nb~u?+d>E`esw)GrsnJyZv>~H-m5AzfJvS|DEM~|M$A@FTXSX@cYsGW6uwbpVmKb{yg_{ z_OIe!S-;hOJO6q9TmA3mKO+A+{__4$|HsSV`=5)^h=GYol<_0eC&tUn$C*~LOkyr! z4Pr53V`qKDwvM%x-HlC>;{w|(4o`M2&du!UoZK86IYT(!b57<^;5yFX&h?oinM<6r zkjsFxn9H6skIRiSk;|3Si_3;nl}nTJJtsTo3eFoG!JG>?-g888)N!zLFmkM9uVHs& zf5Uc`&5O;GZ93~_)~hUhtZXdNEZWSom|d98FoiOHXG~&{W6b{V$WZhz>3_rD_J6bf zZ2f!i_p3i&e`)`*_*MD4<>$p;w|g$0JU?u|9s2&~ zYxDP$Ujx1`|El?Y;#Z#UZC`(UYybN0+mx^GzpecGzk<*lmhm(!# zEyqdD!yJvA6FIavgE@|JaBu{1Y-GR4?#u4VevfTETMC;X+XL1#)O>c7eVrv91sci-5E-rHpW-fD%51iT@mpLUk)^h%1ui(7JZp68o{UJvQdpn0IyE4aH zwhipF*$mii*p{(fVijbIW6fc`&9Z~VgXI(RGG;~QpG>|?9!#Z-lNqNmTxZzyU!39e zzo7q5|Mvc4`+N4U*&m6&MZfd@Z2fiWH~TM>->E-W|2p?W`u+p71isz>{^{$X?~lH&|9=1LvhOdyF8}`j>z3~d-%fn@{`Tm5 z^Eb91N52{T5cr<-BkTLZA6LG=`Qi7&_2;!8tAA$vl=;Q^YwoYzzjS_A{66{H=}*xg zvA@cH-~GMy_r$-+|Cas_{NK(X%#h7^lOd34A)_&KIui$rGV?{2tIQKvYgruGBw3%c ztzxZZ*JWejSj1M%!O8xRBZ1wLb1i!t=STJvoH`tTIAb_exEeW}xaM;Nac$xV;@ZLC z!nKJ*hieH3H&;K$UCu&|1)T02VVoiyjGWim=Wz6}YjT*fuVTN%Cd;14*39;p^%iRw zs{-pTmS`41mR{y~=B-RinQkyXVf@M<&&bCR#-RGY^S}MSz5inWe*N3{$LR0c-=%*Z z{o4Oq^_S%D%AXCtZvOc4)AdL3&%NLO{c!$1<;Tr$mOmQ4z5Qv#5V+27s2&H6t1+vV?XzDfKD`=0gV z%=hCz0)80$eDq`a&!(T+zYKou`Sto&uc*q?D^ zvs-a)U~k}j$-a|QoZ}g%4F@|{1cwAyI)^k@7Kb2L3deuWFph_ujvQM#l{o4+f3aJ0 zo@9T;(aYY&;mR)0@tJKo`*b#Cc0IOWt zG0tY#$8hvNJHxww{{N-^&HU&4_r>4#KX!jl{hsqj{5SKTqF-gdZ~y%E%kO9XuhTyS ze}(_p@blC6*q<}Mi~RKde)$K-_qjhVe#`%{^qcpOwr_?%ioYrT$oVGqqwt&TkH&AB zKjwY2{c-GD)Q=zE>VH^#U-zT+`|}_7zia*U{89b$z>gb0oqyW@y7Y77ul!&7za@U} z{C)1X|DV1;@Bc*no$*)epVz<>9=+4VU0vFCDrW}n9?!*P()hT{fj zAjbpF7>>J~u^i_(Lpe5cx^VPyYIDSLa&gFU-eEt_v5>uxBbuF!gM+=FeH9xwyCYi} z+X>cdtS+qDtlL@2SQJ>cGuJb}WxB#7%cR2O%~;4-%&?PT_J0P3Bmdn0zx~_$PwMaW zzd?UA{!aYe@aNXAhrjiIdH@e_Z^@@+0Ht?C*R(9lr1X@!?z5kG0>N zepGxD{t@u){dd!E*T1WOJNaGX+p+J)-_Cq@`*!1d`nR{=r+wr5aq*kw59#j(KZ?F@ z{BiyJzaO4ILVq6rvHfSjPnBO!f6n>U_DlA+-tXnV@BOy?Gwsi%KOujM{!0Io`uFnR ziGREQ*Zl8eFkpyhe8OPJG=uRklLym2=66ixEIrIhtnw@eS(mYdvZ=7%XPdzq%g)4h zmOX(@pJNkSE5}c^^Bm^vJe-;AmYfsVBRN;IXLIghFXBAHp38ZJJ(hD9yCdfcb}7yt z_GcU!?8`VT*`qiZ*%>+Zv&~^IX47VuVOzzvlU0_@i?x;Y63a7|5EeU@bIkqBj?A~2 z)-%a62{T197BNm{IL~nUza|6o|EB*o|L**&`fK&?z@NE)x&N^Kjr(2lXVZMNd^i1R|NYkww(lo?-2ArS$L?>tqJFIX7Wd=$x2zxUzBT+%{J!8v()Uw8Hhlm8gY$>=&-@=PKOg_N@iXbC`LB0B z7yN4eCG=bIchBz=zxn=@{h9ek=dae^7k>}^UHvcdU-5s||27P(|Fbd%Gn`@k#W0a+ z9-}|AJrfJdO{R@3*~}rVUzy*sR?7RQ>+et`87yCj<)M;2Qh$7Z(u9Pima zaLBWZaJsPTawfCea#pcBaJI8saCWn+akjIwaaOS3}KW`0x9_tiRF!w*5Kqm;H~{-^Aau{~Y@z_Q&Ga?B82|YW}wVdEnRCABn%ResKK~ z`myKd(eLFy+rQiW4FAsm)A;-AA2Q!B{1E)U?}zaB?LQR0@BCr;{m74`?{|Mp|NigC zjqjR2Rer?%to$+a=ZznCe>(lt`L*+B>o4nHFMb{Q75qE=_tD>|AVr*`3&oI11UKIcBg`aBOAk<~YqZnd2tg1de-b zjU2bwk~vPZnQ?4j`_JCXc7Q#Ft&H82O`iQH>mIf(tiEh1te082S^Zg8vK(TuXVGK1 zz&w>Xn)xf!113MFIHrY+*BIY3crscu>|*HrufuTT-@N~t{{;Tm{B8Mn<%iXY#9{{Nx>E9qy^ zuZutT|ML7L{QLN?yx)GmFZ{m$+xk!CpY?xa|LXib_IKUi(tj5JH2)v_clUqT|H%w5 z|9ddjF#KTTVO+{Ii_wQ!mFXSxYNmD;ZDujnMa;8Ug;>Pcs#v<%?y-Djvt$ir?`2)d zev|b*yA+!iM-W>CM?G5=#}c*)90%BDaa>}X&2f*dkK;aDF~?Ole~u$;avUq!9?f9JF72CBg-S^&&&nPam+uM4l`9VDKq_KtY<7?yv6W_!Gs~1q38eg|4;sT|F`>h z@Za3O*8iCP?)sbk$L8IULr}>Z7Kcjve{@M29(a$|UIDY;4 zVfM@QXU?zbKUe(v`t#kd*k5+PFaDbT+xPeP-{*hF{fYQ<>d%WmwtwsXZu=|w&*a~Z zf7|{=|F`=8_y2+a3mDuO92ieC++*}(tY*5%D9jwdw1oL2lP-%5^Lmz*%$lqMEVEei zSs2-lv&6EAvaV-~X8q1Ki`9(%0&6z=f7Ti78f-h+J=o5%$Ff~zPiMQpp3ZidJ&|oK zdk9-6yDeK3yEL0T`+L?~YGyjj!U!%YMe^2}s_?P@|>c9X0WdG0qzwEy~gFeGuh8+x* zj1G)KOothlGr2ICGaq0&$ZXE+%CemKFpCh21#210V%7^RENn`wQEXYP%h^`4zGS=4 zs=&_6=Fe`zR>&T}*1?{{Hkmyej62zr*h<&~*+SXP*>u^(*#5A+Wj(;QhqaomnN^p~ zh4l*SZcZ>#t_C^3|AQ(7(y79 z{=f8}`@jGHqJQW9-T52v&*$&;zlZ)L|F!?~<-6u+U**4Lerf(b z{`1kV+Mi2)+5XJ?#s1Ur*PS1-zxMuM`nB@M$Da#+y!g53$IG8%z{+aft`Op799Dj5F zPWt=cuk62&f2;qU{%7;w_5b<*hyEuq*fM-%*v3%DsK?07w3KlwlQfeQb1%~j=ATS_ zEb+{BEc=;Xut>6avF5TYVco^@iS;jwDVrHM5C zRu=ZptiRaavOZ`bNvuq4daT=6Ke8mUu3=$iO<-Bf!p~yG zvV-{~a|ClB^Jk_jOr1=DOp;8e7?(2IGwLyJV%Wl<%wWkd<^SIQtp6SUSN%Ko@8e&; zf7yR;{C)K&{crxCUw?l8p7N*fx5*!^-*X*ZB-Cw_dz5aRN*XEynzgm7K z|4R7j^vnIH)-TJSvcJrKO8m0@Df7$wr|Pe`pGLo`f4cu#_%rp_m7jgTgnu3U74_@S zujRjNe*gZ}_&fafx!-$!tNhXY)AVQVpQnFB{`&sy|GWJ!??07)J^!Zs{;y=nXE0!V%CLnoiqVYeEaPS-YbI^xrA%{}xtLj5(wH+?_A(!4;bReJjbuq> zoyW3@^$yE3Rv}h#Hd|I3wglElwkpYGV+5B1G zvg)wzWMyKlWxd2=#X5)O9ZMX`3>I+~81@N&II1_4C*3pO1ds{dwir)t{$+UH^IF z*TbJ@e|`LU{TJu2=fBi{{r% z|Jm|~?XTS5xWB!BcmHMlC;hMNU;V%L|DOEI{O|Ms+5heTlNpp5?lXW^4*g`f!kEnH z&2)-!FOxo#7IPodMCO-FpP22KeOdaL7qi@A{>Y-hV#XTJQp`G)WjX6nmP@Q}S-!IJ zvhuO1vC6Vpu&S{+v8uAUvdXYIv2wGSvwmV#VLi{v#k!336-zPeVHR`N$t<5)Vpx{5 z$g_m8++qIDJcW57vn#VL^GBvjOw*W>n9P{oFkWOVX3Svx&hVF^lA)jB_kRV3>i--5 z|NZCqzv18Ie=Psf{`LL+`&ahwjK3THnE!SEbMw#R-wl7-e(U@(_uobqe;&(>dCf7brm_Otrefu9Y(uKeu%_2K8@U*f+G z{c`*D{8!B{;om!dx&8kCtKqlr??b;A|K|Gh?{~zX_&=-v9R0)e*XVEj-^G6q{T2VG z{;%g>??0CR-~QG8PyGM$|Jnb=43-RU8J04nF!D3rU@T|!WV+3`jmefto_RJ?6Z2=L zyUec4Ml2JV8(6M0A7c??VP^GZv1F}d$zWZ{($9L9Wi{(_mIJK6SuwG^rWWB;F z!g_|4ADsT?vc6%dWIe~?%etCHnzf1L5lb-3A{Kd;7?!8Z{47hE_b?|gCozjMGczA$ zTEJAuWXUAObe3^HV*+Cm<3|Q&#yW;Y4BQO%3^V`V`>*!D=Kr>T(*HgF?fG}_Z_>ZY zzdZlM|L*v^^H2KU#6Qx11^!(7bL{uDKh3|R|G58F`y=%G$M2`VF8)6FYs2p)zoz`| z{#Epv^Q(f>sZ ziVXJ|Y8gBkuQP09G+-2EYGo{Ey2W^dNsq~txt^(;`4rO)W&vgemH_4?mTuD1!t;D#ObEYz)@_m;XQe&*Oi>zgPdh|Ly%Z>96TO!@m#yUj5VmxAu?yU(G*X z{@nS!{?Clx1%IM`Tl`V^&HCr-ubaQm{o3+-{jVv%r~Rt>-TEu-ckQpJ-?hI&ez*P# z`#tqn-0yY2@_t|Z)%N?>uO+`Ve;@mu@cZNM`M(waJoz2*$KcP@KaGE`{kic+_OJ2Z z^uJU7ZvXrHFVDY}e<}aY|J(o1=D*bcRsSpgOEO&l-@#zU@ReaQLp~jcF?LJ*NB23d|}jvCN4qlbNTn9A-Ys@|O86ix3Mxs}YL^t2c`! zYYdAMYX*xGYc`8DYbJ{hYXXZHYaq){78{mpEQ&0vS(sQVS#C2sv20*wW~pc1%k0iv z%*?~A#C(S7EK@sEF_RsW7}ICQZH!A8gBXJu-!d>TwlS<`kYfmA*#4i9!T)!8Mzb^m2_3Pa42fxn#e*5dz|z8 zN`L15cK&nmcg7#?KNJ7>{yF$(%Aaq4?)}mGtNSKV%YY-l!2Y$8bbtwDdTd6$&B9^-Z45ex-s=J&SbjA_>xJQ$&5LU zsg`*z(_ZH5Oh1@;m~~m~nB!P-nY&nKFt2CX$9#t6F7pGHugvdQI9NWjh_ZZUkzskm zBG2-GMV93>izv$m7FL!XmiNpFESH(}SvE3%XYOR)#T?6A!K}k~Y=--0B%l~fv`|t1XzhVDe{%!s@@t@HDhyM!x8~wlVzvaIH!}b3Y z8FUyvFf=guFIi9(i*@)SenTh!q(;23XOp}mx1|MS26|Fi!o|9Aa&o%(0e@6bPezkUAn|91N`^|$Mv zMZdlNZ2ukl=ko8gKVN=V|B?JN<&V>!^?wTfT==u(&-XtM{wV)d_#5&!`ESSHHGdEM z{rUIrUypw_|7QKG`}g+W!GBKw+5XS?@B9D#|7rg{7#{qe&!ENdg`tSSgK-zbY(^%= zH;f*PmP|d2O-yGQPcU&aaWT6yxidE~)iZBl+Qj^X=^nEHGb@WZvpP#8vlB}>a~MlM zb1KUs<{Xv{%(*N(nKN1TFvqd%V)kX(%xucCoLQV@D)T4iTIS=-@yyeiZJCpp#hKNa zUopL4+Qqb(sg)^`$(xCXiH~V7<2lAG#x6z?MrX#|3_lqX8P+neF(fdo{V&20{Qt!N zpZ}`)+e{ z9RA(+XW{ScKdrz0{uKT;|C91t^-uh7=|2g-MgOG#7W-5BTl!DuZ?!*5e_Q@J@jLL( z``@{L}!@VPbD^osE; z;{wJBj6sb4j7*F?jGGuvG9)szG6*m@GVK5V=YQV+ZU5!|7yZBV&+vcezfb=h|84pA z=Wpe|U4I?^)%@l9=lu8jU%tO9{@(jj`*+iy(7!!@jQ(c-5&rA{=jR`*Kac-t{kil< z;m@%@(tq~DH>|6}#%-_<^LZ2Gyb>q-^ah1|2F+K_*eG#*I)a; z$NuvCo&NX!pPawj|G52~@<;V==^xg=F@IkF@%VG~kJX=}e~kX@`lJ76^B==M8~#}Q z+4RT#&#pgle@_0X_;dTu^go~e?D@m@_xT_FzhZww|9bwd|J(3)_1|58Z~y)Am;ayD zKi7Zt{~G=s`?vQW-~T`VBLCa`U;4lP|HuD_{@XIJGITO{GF)NkVNhT^&ydL|$he*{ zfblb9FQYNjamIY6e~inSjF@gSB{Fd`_cECx0x%%;o- znH`vqGP^S$V)kI(#q7eomf4zl7PB66GqWsnIx{=7JM%jxMdk}k-^qTP!;~K`fj9H9njB1QJj1L$-G0bAv#t_U<$-u>6&2Z@d-~ZMB5BxXz z-}3+cKac-w{_*|K`FH)F{=dclzWy!zxBsu>zwW;x|DylC`fKp_$X~9%i~hd))AIN1 zpX|R|{)GIU|HuAs-yi+It$$Si*8h?FTlYumZ_6KjI= zC;l(T--^FFe<%G7{=4pP{onI{*ZuwU_rYI@f8zg~{{{Rj_}Be!{=XCd&i`Zo&-CBp zzxn^J|2hB9{9p8+kKyM300w@BNepfbR~hOVL>ad-1Tns3=wXy*Ji_S1_=~ZcQI}~2 zV=U8U#xABmjGLGgnXWN8Gks%9ViIJoWzt}t!eqs~g2|nE2a`YZVWvRlQ%nKO=b5~i z&okLGpJFm#KFlP`yn~6Ac_q^erm0NFm};38G9@vUGdVGNGRZSZF#TYB%y^D*HRB@2 z0>(l{Lq;3MPYfK4TNo}glrqd?FlI<$c>iCEVfFv_|1thW-^^%YGzu& zl*QD_w#LC3LbdT{o<7UQLjBSibjNy!$jOvUZ7``#=U^vE5!!Vh_jvKp3JO3a0@B6?1zwrN<|F{2X|6lU&&%c6y=l|LNTk=ovU(LVgeqbo%Wai@8rKie<%MH|2yrk;@{bS z4gN0q>-cxW-|)Zt{}%kc__z1(v%hQq{`-6JuhhREf6f0X{fqeL|F8OA-M{(&HvBvG z@5#US|0MrQ{`ddy^1uCm!T!dnbeqknE05~m_9N7WxT?8 zk#RHQLdHJEJjOIe3r1^3CPo3q(+rOpCNXSf2xn+vkY)&Ec<^7IVd?)j|8xHD{%`cZ z48x`|JDn@L%)4 ztN*I~o&HzyZ~I??ztw-a{}%q`{+sic|8Le`$-lXO)&7?JHUC@p*Y|Jl-}Jxp|F-_! z`gg_O3xCi3efRhKU;cl}|BU_x{)_zA`mg!lu74Z;efW3ppT>W_|4IMt|Ihtj^#9iX z<^RPPZiDl47lR|iA%Op}=un3gjcGi_sXVmid+$8>@zjOi3p6w@iD7^b64QB1p;LYUSvc{9ynvSVsv z(qqbDl49~_Vqwx_ddmw(B4J|M-7T|C#cKtj3H~-)IzmflD{B{4=^4I)d@n5Zf>37|7x7p4U-(~vf02I${>A+j z{+Iq&{9nmm*?%p6)&9-+Yy5BHU#EZP{)Ya0`#19+|G&C_#{Z`Ni}<(hU(>%+|2F=6 z`R~C$uK)c1HUHcF_xPXpKjZ(x|6TvD{9pT@o#DcNYlbiX3m7CB7Bko~TwzFHU}0=x zFk)QHkid9^p_}mu!xlzn#@mdFj0{Y6j7m%qjE+o&jL}T(j73bd7~7fFF-~XN$GDj3 iGXSQ1a literal 0 HcmV?d00001 diff --git a/assets/sounds/default/checkbox_mixed.wav b/assets/sounds/default/checkbox_mixed.wav new file mode 100644 index 0000000000000000000000000000000000000000..49a70903bfd6d66f9bb227296939d6428b087952 GIT binary patch literal 58700 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 dzK;^IkMcHjALTEWk8J;0Zn813?qK`P0swj6& literal 0 HcmV?d00001 diff --git a/assets/sounds/default/checkbox_unchecked.wav b/assets/sounds/default/checkbox_unchecked.wav new file mode 100644 index 0000000000000000000000000000000000000000..b38d4fcb4480f828633d6f99aebd96339e0d7f7f GIT binary patch literal 33908 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 Q*aL*MInu=v*zOAf0DQr=TL1t6 literal 0 HcmV?d00001 diff --git a/assets/sounds/default/combobox.wav b/assets/sounds/default/combobox.wav new file mode 100644 index 0000000000000000000000000000000000000000..2092f65f451cf0f1d674c49250a4bbb5689877be GIT binary patch literal 48044 zcmWIYbaPv>n}H$RG0ZhBw?sjJfq{XMfr+7EHvhoXVZeK})kb-8G% zNZE}NucbeVl}ODOwUYD~Vil|8O%|--n8TCBw25uzpHB?k-;94Zzd!Kh=}Z6jUC*w* zzWK1}x#R8o58AHF-)_79^Lp#`w(D)zd#<-$Z@qs0dfWAhVE*Lmr>?hMUvN|ChQr-M zw;nxsaJS$Y!{eu~n1MBaXR)9U8j>lHWJu3xy(cfH_7+x5%W+pjOY5q;y=&F44Q+>W}l;Qqx2 zeNUb~jegGelI``WH$fj)eaiWk@%_m!m*0~Z{{3Ie7RVaMBfwQJ_?>sFSdEaL^lmYJ zd2OllN(W^_RHrGNP%~1xr9MHqQbRzsN@JpGrN#l(DvdX)N@^x550!Q)YRTW0W0amD z%`MR)c~FE~+)6-G_yku1pB2jjj*!3cOl!XB{mK8R{oUkU*5{M2zrBC|BIHf%Q{fi^ zk4`+c+x7OFv#-y-*?N7~?YTG4-FtIq z-$UgG^Pl=Xk$J`Uvix1qTc%HMKD_w4^~==X0>88v4gPOu^VhYPVGK)GO3l)h*N~sLxZMpw6K2N$rLDELBEz7ZnyYMI{dv zX8BIVI+#y&-8A3j{{yFjX+qZAecYk1b zqVtCNk>|@*4^}=Cym#W!=i9~ipWITt8+x|q%Qr%LzGA%N}vJP^pa;xQS`6@iEH!^4{{Q>&?3d!#-`}2p*!Icm zt<3wvSFUe*o~OV3_2lj|&c|1u9DNY{==(j(2if=H?=QM%b^rH0i~BnFRqt=SpMF2+ z!NvO*9@;%rf3oKBk7skA{e89W73;hGZzDeNeYo&Bw)^?^d zoV{$)d{SJxLZ!SjL?Z?IBv^!%rCUXh$m&VND14GqQkIsxs*qBJ0B0HnxF#rQQ^(Jf#ODjBGRu#9?RX5;89AJ(NI>Dt5>#?f1})} zkfO|_cuQ%yqMVYrlBfcYVvC%-!YP^Ka!Qg7((6PkBus@<#e(>63khTIVo&Uq*qvRKZcPSqlUa`M%d@l0h*3)NCZJsneUhqi$;l_s(?>ju4 ze(&hRmG{;?a=IV?_{e>WC(RF%pKgBm>Uq)=!`H8$KYa7~<->O}Z*o7iy#Mj_@+YC6 zXTI+Eo&O{Ezwny3Vv?K0WTiPI6=X`J zWMqCzZI_jj-6=mqex1CQg1Ee=!e7}Z^3pPk<<3bym9Y@JC7CZ=CN3@%Cnm{1S@=9x z9KSjHZtiW&k{sF$b}SNqk2B2p$@JIsYs8Pf58j`*zF~d8{N?o5-=9^#=zcok*^$T5 zPu@LHeE9DE>icW%|Gqcn-txPZ_cHD(+<$p@{r%8;>mP}U2%<%KyRu)a>M;wam%{&vh90g7JPl@~&+AJO* z_CV^PH z`a{}8&_G;__p;C;jxW5wn7?q$Venv$`@5L&_m6Xb>c9N@{_DN#=g8Oj@8n;ceI@kF z?S;n^&S$S4>OF3~@AF{6J?Z!`}^+i$Nt#=>0&7UZ^%-{bewGoODpGJwtqaIIoSl4 z^7@J%7i<$}5V3*3N!yl8rnEsjg{lot!ze1QV|DDY-ndt#< zGJCMV63$S;BV2ogdU&1-d-DAiVG}qZ9xr@I>W)~xjFRMASr=(;IZ2sXS!bCwva+%T zGSg+gOYN85DfLF0L9$j#Pwc+W>9N$KACDRy)<0VE z!2Pkn!NNX$9%k5G)C~vOtM7~=7 zy8IHk)$$^;>*OZO%#d3t{Z&>>Vw%)Xk%{8PLfoQC{7VFvbL;Rav%leVW3p#;|7XPL z`b+BXo^N?S+rF@W|N0^CbK&ckZ{wdYc-i}8+OytAkxw)pRXv{fu>O(lgL4nO?)ThZ z^FDua`f|{o?*%`R~i` zFvb)AoLHI}?b+*Cl(`bwU3sgyLIvyjW{8{-R1oVGc9Hle`a)7bV!l*~WVOs!={2%T zW%cCN%C*U7DJ+oRso*NNPTodVOkP7qU+$wMi%hB52?;6T8KOP{XNCT8pXR&C_MMZL z$%1vy9~p+PKUVzd{O0)U&&M@igWl+Vxchv^>)a=bFQz_pc*H%o3Ey?x;J{yU*}BJWPWv-E!1J>y3k9wSx(+bY6vi`1n@iOT))s z-(|n5{4x8vpJCPCUX~C>2M$)&Ze9y6Pr>KBe}wM|=!)lw+>-DUYm?TI6p%NTbyZ|m zc%bB=bXe)NQmdkg(hP-#ikb2s6~4%Rl-H1rl-H3>mKT-@loOE5mVO`-F0L%FUx=Ig z4DTP-100G>$62cXeP&4iweHWGuaZAzec1m=;0^0L%@^lix;&l#%=n4d(>0Ik9?L!K zdbslb-1{|m)$Sg=<8~+L?yft9_midxP{o9vkzZrk3GoJd}!}@^HoWq3WCD&fI?Yym=zXf*l#EMu5yb${$WGlHw z^pBLN#9En2QupN+$$pdXk!MgyRbWuCRWw!5R(d60u5?T8j^Ya0Hwrz{g7RS!8ZzY~ zH4;4nt3B_rF(zwq~8&}Cv~^+{>!^65A^OmzW?p+*85ZMGCq2KzvyZ6M6SBP!r4;7Z;UdLC*#>5rEw2AfJzaNYreqa3O`s4nuwV&UA<$KTe@x!aW zH@VNJy*T=0{?ply7#^2C%zaq-AoKylz0$iM?;70=e9&=U?;*p39goT$u6=6vWWtNb z&+fl6dAa7z%~#Uz-QMbbba~J6<^4y89}?fV|4jS&lp*lnPgZkgKh7|=o7^urrt-CM z-w-^R}@}K9<5PZwqEF3FfCdMP&F8N4|QF@O=h-|C$ZTS;&d*mhM_bFUdn5L+v zl%|lYxLYnz;g8H#Ialc%*_YzhQV&Ix#hHXVMD6)k3ohqo;Je2W${oeBfo;>jj|?F{ zdw;L}vg4cKN7m01-no9*{95VlhZpLvZajVcO!(3DN4yVaJ~(`z_d&+}r2DJy72OlM zx8QE#y>oX%?oYq>;K9rLo{#k(U47d2WYUXw&&uD-dDZ$M;hpK{4IeVU?*3HzBl+uu zzoNe$GP(R`V|&Utoy(bR0e>j>K0$fjE)gYxNO2WmHwjsho08^YX)>&m9P)B9YVsds z9?8nc*vq@hu_-K*_m$6-x0GjBcp@t-e?q!Uc7^0YX$J8aNgH8Cu_Qh#!2?{IcxSQo zafUMTu>}8RV~F~3=~w)xy0p1DoJdV?iSf6t|*u! za+&w9KqF@%PcQQc3B_S!@ zE;>s>RX|aA7xx=J6^?jrC1xu&v3~)K4L_^?6n@S8arpg)PqDAQz4`up%Bx3D9G_=C z?0o$F9@oS2JKpy`-_ZIsdr(1QmZEmUG&cCg8=lC6|yYKHEx_9?c)gzs!R!^os zH+mNRX3eYFA8g-gekuNV;rr4r$N#YXWMTUIcO9!OBNJCNYaQQS&QhUeyiH=)1f3-J ziX=(9h@X~aky@{iCc9ioS;0)%UU9z4B;`per7BmI(v=@6<*Uq8x~(!6gix=-pjyD3K zJfnENGoT7p$yst&x3K&WVi111M6cv-^68DkqmW-DFAg!$+E~}=vPVSQ;rvkImE`2|NQXh z>KD@=4IlP=PJUDQ{=kdruMMAFf3fqi%(JqGiylw9U-j_x-Q@cd?_9f^dPnQ7^WEup z+3y{>+jf7)J+Fs+4+0+5JTQIA{rK7o>u0-OFL_b&Ug(X;7xfRKKhJ-W|J(LMoKf=6 z4Ax=>2hNAg?|Aaq^93GoZ57tx^A>+37$c=F+As4!TwLz0B$vWM83)CSa;l0Q@_b75 zimQ~QmG>!rSGu9_S!tGhmQtSVK84*6t(JS3V0&WR}T_z0|JU&vj?Y|gIo ze-4xP?}mS6-}8R``gH88>ATY(LSC(VQ}}%9%R^5ZpY403^Q87c|3ia&XYP64?YrB1 zC*{ti+a9-n-xj;gclYe=u6rzZ{T@`_lYK1uF#DO=6OmV|pL@OA^ZN8B-VbZPZ~apE z$Li-A2Ft(AEV~$Hb4+E)w0`R+&u_igNAJrV2K4 zUWx_^6BNP}>=ZUB7%Og45|)=&{3bI)UQnh~K2zeobf##e}>zAMVK5qRw>uuae<5wr%&U*gnmE2R&7mAPeKZ&_N z?_vJk-20Dim);e+6La_boiBIg?ykH$@!rdOq7PXgZh7qaSn65h(@8J7pWD58`)b`*sl^@B!P}(k6tz;v+U(r}fMt+aDknCpB zROvN>Y2yERo(d&$SnzFT_TpsxpTQFP%klr*Z{okFe46-e$vfUpJ6~^m_xgGKt51)A zJ!N^g_|czx@((WGU3%}`o#4AO?<~I4awqXl#GR+N+wb1K^XR_x{iP45Kd5|c_^AGw z=+k#Ejb0SIZGQdu!`XK~zfAe`?`Pe2{eLyTvzV&?Phy|Ka+!M#ha&$QZdswvd{0Eq z3-*X_7C9hkC|)V;E6FaKDV-*lA?qvuNiJC7g2E96MWx;H9~8Ug&M7*{#wZp`Cn~Iy z=#r}wJt-Y7q99!)v`}&_?_rVEoR z+vaCJFCRYs_w?FB|Htp|?|9IA@94cNcYE(D-*vrn@6N2-PIqVCo_eqRPVEEPdv6|E z-2eZWh2@tWSB` zIJXLf@V*qDA+SuWM)%4I29D120S zsBlPOvZ9KjvGR4ft%|>8<||al2q^MNipe>P-j`|H&~%A+B6-qL62D}Mq^#u{ zWmx4+6@tHdVrEfvY( z))8F5Zo=En{FEc(zZr|(pLq!F%Sx$1@V>RcC=bR)sljoA~SH54OlLUJu zQbhJkyGxYFu8<0qcb0WfR8nA6oUJfHkza{V$x`{GLbK9uIbX$ZGWqiVq)y516kjAY zPb5x!tI&K=HvSue7q}XDx3D*J9bm3vd-HD+WB)J5zo))M{ha!l`J2**_D|Yx!rp1W zGOSkk&G#nWSH5@Qp6|WA_ZaSn-G6+a>A{(Yn;zCaF?k~J zyyV%fm$zTYz1{VC!-tdaoWAV-$oAv!7mh#SKMpdK{9$HIVz|P-oGG7&huuN&3HL!^ zOFm}tOM(w2xkSH7m5Axfq)D8T?Uu5Y-z>92?wt&W>>Ft}*jLQ_Gk7utrf^K;S;KOdLx&-frR~p7hOIxY{OSAB_T%=4 zU0(v zrJ2h675$W@74wv5%a$n2lj4z!mk5;!7nvgACGcLPgeOAq6~`yu8!Uoc`~LrDN&dZ< zA^ZE3KOUbgf9Sox^I8A(jQ4HNr@nT5^6Gi_!}Cu#?%#iCes}r(@3&d*72OuTn{j8| zUD3Or@1DBrcCY#VlKXZKzdi_hyy?;5r<5tb*Ah#=KVrc5#;SPGJq^ zoXHr)y6UeJ?%i8E?r`1iyCZf-^X}2R-uEs(xc}hgqwq(HPrp3=`NI0S?3+a|Ro*MSDf{%` z-RW=LpAP)y{@(UK{MRSujDLL`Czz~x{MgF)yEsLK+<1PAl<@x$UnlfWN=A%Nc8`Ra zT!K`dg10P>;&QoCg+p>P6gMbDD@iErP~=xKSFl!mC)+RYARQ+=N5VjwO_W0-UtooB z5APoV7p}v+sjL?`IvBsQ)c#%1kpDCG&%v(+KW=_>{9^yM@V(y)wO1>jIy|5A=+@)K z_p2Y2-4}bnc+cWq+?|9w9Cyy&_PVp;w)x$pJG<|;-Qj=GcTe+C+yk{IUmwnVq4n&* z>(?&?-+z4_@%hdB&)*C`Pye;`nK#Q>!fa!CpcWsb?I zO6`+r5v!3j6Iv#!!~0txo1>oRFtY?l`M;-3nZKU?GyT5$x7Fu4-|xLM`CRa7{=3F! zeXsf-1w8wAKksq+z10tI-CA{b>rI_I(YM;}T)&ZbYs$@ww|MUCyR-W4`n$IGFWi%Q z$oRnPY2#zpm!FbI`1bUp>YN!^<&wM#CuN$HA1LIiCMz4Nv8gUrwN>>{U8Cx*Qm(3}#H!M# zAfl`(r>Q6{Qz@?{bzQblELQ5jpn+Ho?_t4U&egmXEO$A?|0goP{iXc>!nYT{u75oC z_4k_#ANXG$eY4;x|I3+=&Ocpsf8`^VyQ}YSxGix{{g(Be$eTj9=iU^(9dk4H){I-8 zw-?`ecPI6p-95R7)elsjzIc4<<)7y#-p0HZ{iN~!<=3uH6+f4M)%Yjy>nLOV-xsXm zjFH@1*uL?*a#soe=W`ePBd8{2AQmNiO0r7cQs$Tvr@||hWlG0Ye=F}&>sLLXrmuEc z^`2^qiioP4Qjao|!UaWHxl#pF=?}6G#lJ}9i#!l-=GPYf#&v`565B(r>x|rNwSOKl zocM0>XZ{zNpWGk2zg~Oe@lo;R)VJJEQ(oFVEPm>6&+pOc+Y|4*-CB2d!OcZ?m~KwK z-Fjoet+zL3-}-Y?`vvqrMC{= zNxflxOYz3Vn`Jjn-h6c9=}o_z|8EB0%DeUR&Z0YN_bl#yx*v8g?1|2!OV2+)NqGJA zx!Z@pH)>zEe%SW&>z5gS*Z(kNH2RaoTEx)Dxs~M;uPrB^@FqSHaYfI8hL-^qvksk?%UjEyeD+K^={U!!aLlzmfYTd>)vgXJKOJ=-xIml{b2q5md9@% z?tGT@WcSPU&-&j!e7XK(`rF-KAADHxE8*+R|6hOpW7hxokzI=^k9!BJIsabH6+)f7 zPenBZw@KEE^2(-2UY9>3!=bcBUPHN2F;lrkNnZKA(tD+L$ROMz!l*(KYc`LD+|EKU%u5kXHY#Q8em_pdg{vBmn z{`2BrwXe=UnLaRoIrM7MyWh_izuNU!?zz?j{m0YpMm$(}o9Q0^Eyg={Z&clCyB>U_ z{ra8jzi$ZMynNI3=Goh;Z|U6cy4&*5?Eas}(hp}oH+z!xI^}uydydyWpF`fQ{;v0_ z?C;X=s~FpV7qKe-zrxwd%)z^#tw*4Vt4HJj-+qY@VO|+|@#%8BQZE#Ff=ZS$qe_qBaiw{3@e2Ghk#cO3r=`0@S4b=tQV?6kmnHOy z^ER&nt0|{0gAYsd?*;!Iz6buk_vym7N$(OrEq)dAuHf01mtBvqJe%@x?vp?FRy;Ji z+kRjD4(Gkf+xPA;-oA6&_;%Ipjkoh|zqnW~Sngz85{DHT11y-KWdFBNj6d*n7s z=*XNCnJRHl;Ef0$Z;{|f_EsJ<<~Qsc|5vhT{CU7&^yAW>=bv+b)V{y)Iqh}P`xnnw zy`K1l;l-4PE1yK)7kQL)cl~{#JMZouzpZ`u*X=uZitb#vV|Q2V?$>+1_v{`DJ-Gi^ z;Zfal=cjG2u07xXX7Nk55A)yL|E&5!_J{PBH-EPMIL{dKcMcmL(?Tw8wiv#%oOVK| zdB2FR7W^prUbIH`sw9uXJ((Md&hqD!t|%;1PE`7%T&>)zyiIwEa=pqTr2v(O^6E-H za+Zp0vK0y!BxlL0ils~S3JHm?=Z_P~=Lr)`XAj{CWDeoj_3t$EogX*{n?*Dmw_HOmV&^z1jd*5=nYklk79r4>ncW>OzyZiT! z=)K5$ukJZLaC-3ik-TZmehv#3Hd{X`O{9EQf{@>@B z`u>};o3jXTyK!vbbKp@Hl;u|wZWdf8nj<13X)e($^HBP_T#?*Cg>4Ft6vdQ0m5wSk zDW)l9$j?$>lIxVeC$n2lS=vojPI97jxR{uvkg$N*dj4lZ7Cc+|i#YUnwzJIPXl9(w z+V}Su4L__+Joi3gF7!|z2rNW6RZ z-nBdH?n>SDx*Kxu;nVwj9veU8d&c~D&P%ChZ{GfVsrKpoTl;S- zKkoSX<_qs%@gIVWynn8-oc~|UQOi7^=M1~O-~^sFQ7OSc;unRJq^iVD$a+h$$zPJ5 zEq_90mZF{f4W)ewdz5*V?kO)+l2A5OvQv7aI7xoDyp`-R*_AT8Wc#H4N`DvIEs-ib zSCor?rl2zyKd%p466aE;o2+&J_cKZSDg7VyeZ#M&&-cDHyubRX?$!FY_ny6e`Q!1v zXO$0`pBUZGc=-A5iTm+)U)_6u`~97rcRcTQ-`#fi2a~!z)j3 zK6ZHd@Y%gLX0Lv~=X-nm^WhIazwi3u^5@16JH`Wl{aBAO@^da=eaf?lGhZNycfasE zfmHFEA{V7*OK{2ZO3Nrz$_XkRRS;16sSv3=M~O|PQF*#@kg~F}p|Y1!ztRPTNX4ac zN%9i1GIHEfp3*Nw3rf3G zZYi^?q$#H;2`Y&xiYsy`_$aW*O_rM=eCb~05`FC@h!j*2sh9u*M~{w%ai z@Va0gV%cJHCGVcJZUk=Y?;-znl8<`Kxu$-aJ=- z68ZGd!z+)zJ-GGo*Mt2J*E}$L==5Ov1F;7_4{kj;|G@OopNG6pRz3E4w*2X&7e}90 zzAk>1{!aJp(GTVCJw8AASpSXpYw@o)Kc@Wq`zM&`0K*zq5f(?zjqDS7__)pZ5Ab>k zJr$TDY9q2+B3pd5bgGo09HVTOf}{Lu#pwz!l|Cs>P~M=VuQWl?L~)OTmjbJTw4AEk z8)*}n1W75WOtF4(3!&4(k9gSl^*PsYbFe+)Fkq@;J@hY}Y3r}m|6;$b|Ml}z)pxNE zJH9Z!o$)dE<-@m)&$hf$eEjbj_xlj|(2JefH{U&`XyWzHcwRcKn$6{@&+jAGds)_9f((;Ey+dm;9c|X!xI< zwT0ehyw~p@;lSMAi#E5Hl40Dw!v7US^_nzPz*COoavVNs9Xwe3cF< zdMH>b)X3kE_fmMQ;3-!vuPbvwc8Sz38ApjHQcp#`iBA_060zZ76>#P7=5b_A=X}Lz z#CGeiH`B|XIsdx8%KY;9c2cPxwGR{?+ujYlzw36y-F3Gw z+&Oh?!R?gW+i!oqbN^1}ym&J0ICSPAIw>^jO(UNk{pe;$;OLg#tNGxnI&@GIEmtB=y8H#AAg% z2;bt}!SBNv&3%GJhT$TQBdvy4`(WM>wYPH%lUib$0J6j zzw=p-Gfd;0%kqo&F^7YYGw(N19zk`9HsQ5W1!9%5`y`hriz1+Rg=^-l=76X$=Ks^W(=m^DmvR`#&hWxbw#T>ExFkkE)(2JXrBq;GWt;^E(gk zN#Az8yX#ie9gW-Oce?L** zF!>(-#pIXE_ZNRp{*q<7^3RFwI@5HnpKRy&{&C3)>++uzKPjv&BPih|mn>DUa7QLg z<*)*S>MKQY)g&cT^;Ifu>eE!c)HkXIs;Q{PtGrRJRmxH7lM|7bmUfVFm)s-uPV|Df zmEc_A_dN6Ymvb!V-odQK?)m=~)2(0g{<(cO`Tgwkk?*hHz5F!)b^Uv;7e`;WJXU_5 z{BYTms{8$q8txiCP`y3>?x&j`cb?olcl+9n9k-NjFTbOF*Y4isI~VTO-)DHB_9)`f z)5nJ%M?5?Cbmz-cFP6SFeN*vq#rv#p++P@enS9^)x8;`ybI<=B?CY6zd2X=p5U}Ez zEFvoKTYQP|8tM7s(el%zS1Z=YWvi}G;?X#&`b<4mtx98`dbLKChK5G8Mzp%1#!l5w zYKN3ERGj6r6**-KY6;#j5!~3q9m`#IuO`1p6xndDgPOpBeRkPWoH@neoTQ z_tsw&-)4Sff9dkp_?hV|-Y2`CpM2o>A{c8V*)9)64ne$2N=k0H+|G50> zV&M6Ao7I$gJr^s-Ek08o521bhsiJ>{wo4R>otHi&Su3|lhDV`R&QVcDK~6bIX{Jh& zN}}pM)t#!d)znnm)ut=wsm3TSP+lzOs`yCiiEO&qdC7Vq4KW!$QK6%pynKr6W!&Y= zzu0^JsWHF(S@6&9>#?649}~Xrd2{`P`b(a-2cCt#%6PKdMqebj!(0 zzfw3UtEL>S@J(fr(pt6aDx1|y)K06Ls28bMsNYiUQ7cm3u5wvnmeLm4|MG7ndu2|F z+!H@2U?4J!S5Z)!<2`pLb0&K%gBpv{pI`s^ejNBc|Fh%wm+xvnO@GDoPV;%jtE*3r zKM#EL`ia$p@JEmCcHYmqJ^xPEErr`xZ+^X*ca!nvm75Q5Y`7hLYt_BSccwg;dXM4p znFs5i`aX_+@$l)HH)5}pK8C-u|LXf`#?SU|-~K-PrO))^pDF8q#w1QTHXFXvTt0%2 zd0j>B3OI>>6h1F`L~NPNJIQ2uN!i1Sf8}2)uT|QiDy1r@E~Z|iuB1_-c2qr1)mXhm zWvyC^lDMk7!WX4J*)Rn+DK6QmqFNFi{CqPi83iweferx1#TcpVU6vy?gV{_0^JB!q4QMJ$ShB;lX=>_qN~Gz8!z_ z+>QDhSFSIB?z~ubz3uvw>uuM~ZnRx5yt((f@NKu7hIcmI`te};{n^i|pDuhQ{p!re z?2iY&U;e)Gm)`Hrze@l1GwfhI&$^c_i!+?-JkJl_IYOI-pNqPRdr171d?>wFR!q)L zVWIqMMKi@2%Ey%4R39oAseMu2ueLyCf%*j1Dh(0U42?{c{pumg{c4vLUMfGAou=R* zRU`9Sbb)xfV408%?^ND;&IHbI*4-@k7;gXX{$ugG`p1iJL0?Ng8-9}i!1I3h8{Rif zFUww@erESP;|bdn-G{ahlkcs&fB25?y_DMYT>L*KH6uwY@Q~GA}`<(|mcC=|%eSKK21SgA#^P-TLWr)r8agIcu8A~gqP zf7Ld{4COltX-eni|0~GIGRS30K9;N#?G`;ISSIMem(Sb9>A`W8MVHx-k>kJV-wnU? zemRack$zYqza#H{7+kn|XiUeV<2<9!-AI|8)8@nHOxYkG=W%j`t(yr;;zK-!}i? z{B`Hg=D$Z6A{i&MY+{k+;N+OYy`MXbpP7G&&`!Z3(HPOc64nxzq^l$)WHqIy$T`Y( zC=|;#DIHWOQJ$>8s_dbVuC!miMRC8JzJi9Vqnx1hG3iXn`I3F&5#l_eks^(PfdWyy zPq~G;tT`65wlXI&*!`>etNQ!*uQ@-KeNX$k|4ZwqqaWA5KlSeQ8=E&3uY6vve(wJ4 z=98Ky5|39rihJnu!215Vdk^m>-rau3>dw>KHh1*yY`yd6&eFRM_om!$eQ@H@@5d=m zUp-s@y!)lzYxB3(@A^JG`WW`b{oA1*Pk&bY+3;6}VKU<_<_oOn*qJz|b7gTa=E>q` z6EG4A5KmI=!Cuw&?wnkE=c(`&#f_=2z|S^?z^w zThB0&aWk_K%Re@8_FOJbu4>+1o-h1Od@@2N0*NAj1dYW~gm;K{ij+z|6IGFR68DfX zln9m;m$a3;Be_*JTXKqQpk%*nsAP_Gv-mSfIkEc^0iwsmW(z+R{wKgAu!`51H-tNz z>pw>Z+hSH-=D$on3|kpw|LXk{__go1&G*uuQ@{9qH~y6LRrbT0&rjc;{dny4&-c$> z`n_v=A@p|Z^UyaE&v(AQ`7G{r$Fp~@Ql3qGwe8ugSL>epy$*lb|EBp>3s#n-NnabHS4w0)lX{_ZFJ_uZe0-d+8;=Ix&kI&Wh?+<5coz1rKC@AtiR z`EdB%&kqyd*MFSEc$lxll52Kk0(Dre81#V*gKPtQ{Pm- zKl{4*ox$s!Zv|d8zA1jW?sfRf%-0uR?s&c8Rlu7IudUy{d&~Vk@`Le5!Oz=2fBKs7 zt@Ou{A4b33f1m%m^)DyG*8g`H?HG44-(hlK6Jq_$-o@t4xr}`=_iWC7-azhee8+hv z3-s{`2w4bh6MipPEV@tlnOKo%yo9}Yp5z>fyOPn8lcb`h&PcIJzmnW8^+du{YK3@; zB6ydpo3D?(o7Q3ajV8txQjUwf~>$LpWQKIwfb z`*hw@lV|MD&OiVA?B>hd7pq_Icsb*($m@siH@%VkWb@AD%lY>O-`YPO`BC^;@OS9f zzQ4uavlxVbU1jR{W5UY&{}uZS#&ulRS@!d)a=Z{&&V5ywhrd+JR_K(3nP`pFH1Q^B z2T6CCMN+Xc+0uWc&r0`5Gs-kd)kup=c1op-$4j<|a!W)ACyE^x@DbU|w?pV4kDFjE z*CPJi9EE)U*vxsOSif;|vE1W&z;u&yBI72G?*Dh$Uj8*_jsBCtyzFNQgXj07e~rI7 z|6c#e_{X9T>0e*Jd-N&&ZRZD;H%{+9y>fWt_v*l__Lsgd?O&$7Xny(Y`O}xnosOu+LOr*h(0A*%g>Jv)eH%aM-b=ap@w_;dTG^dFy}-M^py@cVu3NB3{XpR0b)`^oS}``4B~ z?7#p2Y5y(u_tx*wzkYve{?7a3`*-r6V}H#4eEU7|H|y_1zlwjE{d)Uz)lcJ}?|-!Y zu>CRbd*t^c-@3o~d=vTh?d#sJm0ttCs(#h`I^*lLulnC+eq;Jx|Go7G^Uu3KbABcM zX8M!;m-%1t|2O}CGR$MNVfxIJz%0np!Xn3-!1|t5lWj8_KYKkpH-|UJI}Sn4*_=l? z#ku^rQn{vco#Fb-rN*7i9mRc>o0;bz&lR3iym7pJe9nB!`E&R=1uF%jg|Y;v2r~+m ziWm#M7r88WPUNuwkH}2^+d|iP6$Ni`OYtA)e8Q8)?#Fe9rIOv0sfMNfe+lD>Kbikt z{Pg`D`n~K2|JSEqFMrDYEb_7LBhv@V50>w(-XDF}_O9n0=ew$RYv1<1D|?&wPWo;9 zyDM*s-_3pF|4#ai{ksLPZQfbEHhy>iRpGlmukOD)_A31SwO3c)e|t6iL(A*FkD+h6 zKiR%z`!fIC#II@}_`e_f`2EMH&!)d8d^`BJ`ez5j%s=(aAOFu{YiF+J{KEF0`y(eO z-(;TC{EzrJ1-A&i5mXl1DwHprE<8`UC%`c0;HhgORCx$m_=Eb#DY-D8HTXV(;7kFZ|yny}bYK_{)s< z2CtsHZ+n&d;lV4Gj}fnze*E$}_|uX%ET6;QKK*RT<2iZToNiTMbf6w4P97S9k;7Izln78ewHFSbW;yO^nS6&LecYZbN{jOIsA8cOF{BYtm z=SSx^O&>SBQTyce_U9+9cW*vBy#Mv3@q^B{&mXJ5_kTYB!}qJfFT3vtewX~5{`b@G zTmP^Bo4{nrRKa?k)tw`Z^D@_T9v0qO{>^+h1kdyL3r`lfF5)iuRrIgmC$VKhhs3pn z!zJp4*Gg;_zASM@__o9@;e!%s!pRb+g!YJw2z?PN5_AyzCvZq~uRy)%LVTt!_PtEXEa43Q8Q%Q8`nUD> z(qC#n^S>Yb=JqA@i|NOIAA{e0dw=+K?7I~&wcc{Q2z>M6*@f4(&vw7&dM5cM=-J^n zZ=RLCo&WsbTk{vu?^eH9{!ZZK*LO89J>GwQIpuxkt84G?y^{Xm|GMJC(bxY!1ijh# zQQ&R$C$@JXpEcimf2sda_?7e1$!{k<2mCnmmG_t851v0~f5ra0`u8h?KI0bV?JQYr z)*KHwO1b}WIq;q1St$_8w@av(KUbtu;H0Rg;AOG3f?eVgLf^&nh4ds=3OP!w5|WX~ z7g{eaE+innS1>~?LGXm=7Xdxd9)U$7J_05pK>`dStpdyKfS*8H0ibRQ>WL8Pb*%RJ^lPz;n|`$V$WmWD!=&uR`g~2 zyO%HjzuWLC@qNbY74O;JTz+5s=F|K0Zv;Q^zIFQ${C2^Iy>C@MM!h@vQRn^CPYxfZ zeqQ?V#23%c+TUcpp7?I{edo_JKbimB{4?jD8$$&nBTE^}WA;e)h1~BszwjyZ>=FFS z%PrE(|4nq6K!JF^V5!7ML3T+WA$!TiLVl7TgIt4sLQUu-zHwj!5UMz4yxL07du(g1y@B#i>Auj$d!PmTE0zN!EeEwW* z+`=5oIF_(hvr01?Fim23@sH=<+}{&^2mG-5sr6O*d%!2HuLs_Tf6jlq=3~KY=MUFj z8oXcpqV3(Y=icwOJx_bb_F~_=jW6upH@rOf-u;!#hgYvGK4iSs{&4QK*azV^Y#%h= z{CaQn=I#4{H@Dtbzq#^$-kWdlkG%2tAoBLmhmCLZK5l*&{fYlW-RJoquYW20T=Q+q z*Ppw0AS0;XDVKxr7%bfc-EO=$OH3ay0CkwUl6^cyZKPze}utKa& z;HTIcfp22Z1fGex2tF2@A$VGBmtdXPMnN929>Gr0P(emf5y3W*vjXxWa|CV)FA+E+ z{8Hepu#KRkNV1@uh>PHJ;im#Q!Y%^Sg?#wE1mE*E@tg8^@M>`_;CjryfW3e33)T?!5qjO$sWMQ!TpTGj_(iGQGvNUYlLp{ii)h^V-=O;&lPp&&lj!dj}d*!pCg(e z&@XyKV6G^eV5_LMV34S_Ag`#l;5L!J0+AwT1SCZc2?&b(6>t;D5u74&MevvigV08i z`+`0qd4fH{3j|_>a`?9kT;rX?_kvr1XA7qaXDIt$)|)IlnPQo0|8p>u{(10s(a%%A zUwm8h)AGx-@7q5v``Y^c(dRjD9Y1}0ee=VbSLff~d+GFk`b+)yr(Y(%w|~|De)+3e z@1MQue=qjB{Jqm_%lB@tkG@tNq&c-G|rz z-?6;?{XY2}@5h(#l|D6pjQgDU`Qeu(Ul)Bd`Z4Fn=U-QU-Txc?myuDAA&td=`6s&o zTMTz2haO)wSAu{XkAhGMZ;NmlUxSD?zp&^I{zOrCfu*9)1sKJ;1oOlsgpP<+3*8pm zB(z!Vs8E*J8X+FB9HDulnnE_B96~=t1cgqDSP3lLa_~Faz zneVT?W_rKyb;Y}^*PQR{UT=S^`?~e5=j(#Ey{~KE{(ODp?V2}!@AkZve6RSP_rvB7 zA|Jg!nST25dGhDqU;Vxo{|NkU{=4xf_rDjvzcO_H<7Dw>)MEd}tiYARc7mscLyX^t z>!-j5?iitsJWj%iyk~?T@=A#$@I{KS@UIa$$}b{XBhVnqDR@ydT=1r7l36z z#Tm&Z!|uWn$D+ozo{^P>`~OqM;y;)E|NMFV@0Rahe=q&2{A>H?z#nHm)_i;Qe#aM! zcgmlyygBu8-|Gt>IA42yIQvTK!;4pc-wV7x{9f{P+`DMwc zz;9Jw_WYRsE$8?5AD;iF{dQ+e_!rA!z?jeefH{aOfbAhq8V3u%2-g9DSZ)y^AD$mV zT)b_{;eVAMTmD}BI`Q|C&t*URKbC!8{(kXSmUnDl*1TE%X~FBM zA3wdi^Tk`8OY@%p|3nG}BSmxtVuk1Ni3y$KNfYSf^5%QW&c>t3x|OqzDT4jje+E{* zzlWHbe$8Ug`%(6f`CG)F&!4@2{r?#BL*+y6x5{_xz9_s^`pp04)<^r-|32J&)$-xx zE2R(GucyBcc>VNU$ZMTyBn!fIO!~c5EYo^!iuO(iWzw&!6^J?Ym4=;7zJbQWZ z4acjcZ;M_Xc&GPT{)5(=l^?_2Mtt7-&f%-yhlKCypVs^g`=a^h(YIIs_WgXxc=``F z>*N2r99Nixxf5CIcnjI%`1v`{3%GJ!6V&Cd7GmW2F7%WqU-&%lHR0uaq9S?x`XUAb zh9ZvyL_`_{9}9B`trIR3$`Rft#3w8zyg_KLaG{WkNSF|}Xr|B;(G@~-#jXjx6q_Qn zS?sEykk}r9zaqB$CBn|U(*-|rb@5km#PGgnW#@Kfp2*R}z|OYqZzc2lUkr>vKi2*G z@-_F*{?8V_7JoGSar%AIx1e{MzDU0n`^@`h_eY=Cr#}39rTc;J^~U#pul3%~ecki! z%Ih<4U%meE=E-aRH^*M9zFzb?<<;WXFJ3->z364go1&My-Xy;?dpqOh-?!YanBH%F zrTbyV>&A~;-f(^9es}cCg7;g#m3+MYqy4k`?<-#?{;m9B$58g$jJe^TE?X?)1CGzk z&fIcrHoV8#SMdGfn9l!^(@o$e*ByZ!+#!O~dDaV-^ZpV{<8u^BHPD6QTf;G|M$M9{XO{A;rFG_ z@;@0qaew#zAoKOgyV%dm-ah$w{LQ5gL2u+fxW4IspZDg?yUA~q-mQG&{&vrs^fyP} z)W6>LX2z>EZ_d3u{Koku*IUMyjc-4_5Piq_a`U^;ml^Nhzl{EH_0`mm_g=sL^y-bp z7v^`X7m*X@Qh~|1JFo%1B;6)xWp+CIELc07%gmMMMgeMDn3zrEw3X2P~2+t7SCd4n2 zDAXb%DD+?Cn&1S{ZGv`UTLu4$-4Q$>t{~(uVJ4I+abA#FfOMt#2m0 z?0R$M#e_GJFD|`ddm;1o(evWB5-)h(Eqw9hUE<4o?;T$K_+aqb=#%xEj?Z;(dA};Y zfA&rLqv+2GpPPP1eRKR*@Y9oV;-7Gq`TwoiTbZ74im|HmXtCeqJ;fowug>+KKY@F* zKqZfX;1u2kf`|ANg!lwj3RMc42wxFeCj3xCbP&JoTNYZiVa79kuh zZY%slJXm<6M6+j)#i^eub+OJ{wDK_%{#|$(I0YuJp1(J z*QT#q|4#f-#Zda&ggNWqM^-P!E{^NWySP}{s(G%lU*q-WJjvJ0mBBxo`v(6;9vgvM zylVuM`1J)l1da=S6pR*95#B5$AaY6QhR9o?UQu3Q3o&Kk7h=}JE5x&evn7@Z%Sc`k zUMu-lm`m!8Fqc%7aKGe6q3IIkg397Y`OQRU^PUsprzVLqk^o7QUoiCj}UVF9RlgS&iFIV1jemnMF`p3VIVZU;|toy_9UHSjv zpGz1g{ZVE~`ahG6lj$KxGRsM>Xf|t}Z|q*Y4xBuEpONd7nYZ5 z6Fx1uM~F$XQE-#^P5y_XM|l?tdvc2l&gID9d&|0o+mcy;vx}jg?d#uv%oBc3WsLh7 z`akEp&ELIW&3>DI(fhgclkWGBj}c!@KAicS^1k`gqjyOk7rsmT(DZKd`@(k*-xa-6 ze^>X;{%y)T**9$O9=+;(yXqz1+len0y;=D@`py4m9&eUB%YC!(*||4gp4GfqXOho0r=^guRmeH0kxS&wt<8eO>+T>$j;NUj8`rN$R)x*Sf!7zl$#$N+g z6^1DGMy4Rn6)e2m6WI!Q^f?-N+c=~64sZ$cOY$7#U%=ZU;LIN*_&`8MC|Br}&==v& z!kMDABJ;#pMQg<^Mdc+NMYl^>iH1rVh`yCH6fKpq7QG-PBKlNnw#Z7U+rkV|2ZU}( zXbM`3Yx8S~-r(6TWY6`BzldFqcM*#d*GT?C+LumwwBB zJ@Rwa=l$OUKi&GO@X_{*{fBp-w!gppG3ou&4;JrbKA68xcyIE4?mN-<2i`t-x9`p5 zcS~QZz3Y6nM&%57+zF7a>`Q_&i zk*_>I&3=90v&7rPuMgiDedqfa`g8f`2fypSP5+nl(}Yp`&qC%4|DLc$Gk#`2!hC}B zH)|UA1NOZo5*^}1`$`O7Li?2c_J^Qq(pW}p$Ks@BbCCtzn$W;mIF(2zm?67P1!U6aFh&Dv~9xBHAL+C>kv}U6etpOte|b zK$JoHy-2n60g=nnc_MG6?+Ble78j0@))ty2^+zCIGM)c|crNcrQEl#W;o}^i1tQse z`93nw;hxC&m&5jdC!6eFCl;^YJWMNpUivTfW6Iw--;#b?eD(VI;j`=a{hvaf zdC!MOA78vL{NVIn$6|Q-Zp-> zc%S=o_Q&bJt-d_}%m3Y%f#=r|Cb_@XtXd2c*+rQ5a2{aY$Sum@$1BR!&-ajf5&vx7 zwE~g+>jjkr7YKb6suA8T;v!Nc_Cw^m_$*OddBa)su%|L@tp{q1H^`Xk2V`)l(5@*hfn_kX+a%i?R#k5ivLzeRrH{POCK*1XyHLgr1w^Ubd#pGCe7dn)s~V9Hshbp8zSJv?<;soKtw1= zXp_(lVF%%G(G$YE#GFK4iBAzZE3rZ(MlxSyrQ~nno00{>eD}i^se@;-MgMQ zy6@J#wtRQ=mHoTDFIC_5y}13>{dw(Mv1fX3MW6n7->D`^B-(Jg?lp{&@ZIo9DYXKbSvS|K|R(<*)B|dxkB) zZZk>$t7i3Q{K#&>BExlo?HRW?rwcDHwI!Ub++#)qeP+PiDuwObw@PxFU-~s6e z0^!oW0);>z38&&%F6Kl}40;A!`p=qC%TO{9o}ipYiIS^UOc~o3VXi zs^VC~n#;w`@s^vNi=Fob_dVW7-WhyH`CRzr1n%=U2&M^q6M7*~Em9`p8nHjKKv)1;n1(8e{cNw_v`z& z+#g0?e|=r@dBJDLPX!-YJ~q8)`f&fP)BDOdYv0+twsg`+cm&e{}zF6~C`+414 z&S%N!|#~hl)aOA?eot4mEF78mum0)U)*`C`#kfl>@$Y9 zmQQ!RnfPSx8{;Q?-l#uOdzQ z+=1N3cpP{V`Sf`i1^D>Z2;S!l5T3(-Tf|PlN$iY3v$(5Zx5QMzc*)~}5>kH!Pe|Db z6-YM=vC141DwO#tv`&Uk_=F6j@H(0ELY6XVLO#;V1;0qP3z$pr@(YN$@wN-QaUT$n z<-Edsp6xVO4$B614yL&*yZ*0Y?EQP;U-@sAKRG|We>Q#J`0dPB>o2Zf-hI6H>Eipj zAHTj`@FDxnm-l9`+ul3Ba(Q3=QuqD77iRB;Ubwu^es1@E!!z;s51w9sC-$`eUD=bw zcd}1X-ibe1_%8AZ=lhdS+TSNV75!lJY{LhO=P4hHUbua_`!euz{pi_J&y^J{wCzwN-U$OpT-NbIo!NaMO@A)YBY z6lHb_MaY~NDw4S>lpu3l=)ZKGkhb(g!Ap{l1ehgi`7es@|e?{`O@ zFuuR`MB%;C)42DWo}PH${w(&xl0YPqnYld^UJ1@Kx!3?>GOC7C*Lp zX8aZK?dflUpCA7|`>n!o_Fp{H2F7}p43;FeD{NocKXMpy-r~~YTEWB09m98ln?YbA z&wN22UVC9izS|;;_(H{u_?L+9bbq?=?#xHuw~ikr-aPrB z_xjj}&R6e02);7?xb9`?M~9cwKCXRH|MCBG-H)EncYm1k%;&@3r+40WJ*|JA^EB@L zf~S@5`Je55zxtWNhpgwTKE%IB{y6!i_osia(m&66v;IrnJI!w!Kiv4P_W8ii(_e4? zUigFS-=g2{44eP8Gp%D>#gfm`%65Z|gX1=b5@#dVb z-=hBuf1mkt?`O!bkRPHy*uJxU`}$Sn3)ffUPZnQFJ~Vy)@b34geQy_jy7DIH6aSlp zPm!;4KdpL|^NHn^+b8drA3i3(i2a!Ie8q?QXFuMbd7Ak?@afNYEKfJRd;4U;JL#vV z-c5e0^WOj2iT44|mwf1dvHBy+t4p6Yyq5bi`R(klE8e?*|MXGpXVn+x-)`U8{#yN# z|8McvhtZ6oidm95n{_VhC$`n>LL70NcQ{^i6>}Ew{NsGbJC`e+-=F)jfDli*&~2W- z!drOfiZ0@l7oWhNCXpskE-5D%CAD2pK-x-Zj`SiSVVO@tbu!w*M`Z$pAIKyLpO$eG zPM5hUG)tOKC{XH=K#PPAzk!$^Z;|jHt}1~pjwW6cwtg;lmf7rY8TYU}{?Eho`)|j8 zw?CSHFa3J)w1`u66N*KgN8 zo$_wyv%>d(pVxoLdAaE$<7>9h$KKR^x$;i)o83q5AAdf}{Cf4x_0Pkf1^>?fnZ~g0 z|5B!0rh1k)EIe$I?9S}3IHWl8xE6Bk=jP;m!&Ad4$0x#7&3}!HQE)5wbfI}XdLq5N zM?^dM9L1aY>m(8c$|c1FO{JC#o|F<3@{&##+92I6^iq1J&{OFtLQADHgiNKGh03J7 z1x+Ni1d_!U^2v+tz?e&ExxZw?<%em(pC>zC4Z_FwGZ&iO3%=KZHfuQWd` zcp36B>_x!`>E})FnVv0uC-L;#+w3Q8Z&{zjzGZvT__p}T>$l2J8{hqX>i?eYdE^J@ z7o8uszdZRV?X}{Uptp0rmcBRszWL+TAHrXnf6e^n_{Z`m@4r{SU;dx|Z!e=VV-j;C z^C_0)tUp;h*`KgUakjDNa{Xi9!`;cD&+E>4l24zjTtJgsQb>_!zA!g0x9A(*M6u(1 z)5JUZr%5OY#7j;T_$zr=AWw=>aElbH-~%ak!H-gWf)AzG1XoL47qFKq5onXV#2+p3 zfN#Cn4BiDI>^z}DUR=NUCv(i>{m-V$UBkMF;|a4kn;Fwm=3WL*Mz;T7|1J2t?N9KZ z8NYabPyccF=hAO2Kh}P6|GxDT>$gWAE`AAkfBf^Ow_iVPeB=D7^7W*TKCiBS(0eKO zLEuHs`_In~zx(sl{GG?sn{RJE+4OellLK#8Jo)|h?USf?_N>r`tc;zf}Da{ciJT$4{|;;eUSpfAQ}OV-#a1^Caej zES0R+SVh?vvDI*xvmfK!#r}uOlf!`L1xFe06wcdxphyOQ+D6chp6IVMcGshj~m8{lGMapQ z`mw38b+d0}TghR?{)qDydo=fA4guai&c}RHxHbzc;7${o$MZ(Gj@L=loi9f01)rIC zF#lumnfxUZSNLB`a0>)WdJC+ToGtKH@}|5&0Jk24+qKZn8VU-G}Rf0X|?{eJ&**UwAe zZGN2mdiLAV&(&XVeGK~|_aW}{>~|AB1-F+C_KYZ8! zto+^0r}po%o*KM!ed_rx{pqB4r=Rk_&v>@!y~*>u54taWKSsO^{Iv5`-e>~Wnz@$w1d9dhA=d3| z-E6`f2J9uA*VtcjP2{NJN#vB_v*+5wufv@psKX;5Y{9!y#F|f1OoKmI{5^l5M7;pN zt411Vz|1V(p@VES5=AV#1FMgT-s{P6MgZ;;W zuYKQ^e75|0@uSigzYjs5ncr{ubmgt$r!29*IPexzTNgE>D{@n z@82_h-~Tc0$LY^6fByeE;kVNdi@)u^F8w?3Cye3VzXOc77}%M&GwHFEu$Z#4u}QI2 zus>wG&oPrdkSmho3b!_ACa(mSB)38k#%3m*h z_5Qr$%YlziJ~w=D`JDKE-zTScHlK9g9{#BRChDX8>n|UiUe$jvdHMal_KV#2w$I9=8DXT9I| zZO_M~AA(=he`SAr@_Xlx{=Xl7>HO#WyMlrJ|7XT43=+&WOlmBwES#)4tlL;Ov$?W; zVSm6D#kqw23)eJ`9XvZZJNZ6w1qvwfFbLW6P7-$K`!Ax)?T>jsN82R}0#TQcJd<|qGeG8X^4^`HCi*T08;i~Q;TrSU7`r`ZpQAAaB7eCz)5 z;w$54fv=lB#(kOg;q>Q;?`=Ofzgzq%<1PCqyEg?NSzaIiaOS1JhgC1K-tT_?;+^30 z$?pz7YkYU_*}->a&kf&id%o{|%!|SgYA+o=O1_f$Wb>N)bH^LSFHG;!zV3W~;oG{8 z5kIbe{`Zsb+xp+$Ka&5p{1W-U`Oj8{3;%SOPBYXp&tO{5V$8CUbq;F@+b1?f_IQpD z?7unRavbLt;#$KS%{_^KJx{jaJ6>I3cK-b$KlsJPb_=OJbAtSV1GFK>6 zYK_oksaHZ8(xSp{(*K3Tq-O{sCHeQUFXK7G z>cI7%`477b(^b}a47-_m|F2-|`MdbP$e#^=H~u>NJN@UgpK?EbeZTQd_1pfhjbHA6 zk^Zdt<;lm+&wD?7`854~@~8B77e1Q4b^OTlX48i|uXsMpdg=c@{>A)v5zl4ct$B9q zZPK$_Zxf%1zMJ~2FFW+@OZ+Rd3BJhLfOS6yZue3iMcpdRM`R&OsUhj*(C4V&k zG3&F~uUB80|2X}4{dd~0)BkV%naKF>pEC1DhB}tnOzT)bGtXg@VRd2mV7tw}kbNnK z73X?RF7A6=-*}{XF7o;CRtSXhJrh*pmlr7a2mVi&-j{ux_wMkAkhkyO|9#E%e)B8acavT&c)Rn3#arta zoNrlQ@V@>3-0ZFFi>kNrFP^t z3!FXN_1qJAgm|lY>-jwRPVoQYdnqu5|C6ASz&D{40{?_n1UW@l3G#{A3-O6R68a-Yb*%wGS385jR4{LlS+$=_K&U;PgJ;r>hY`;i|i-?F}kf7Sc8@(agT z-Y?HTmwjIM>Ex%-j~_oidH?Z4(z`3~ufAFJ&hvHq+dD7Y-fVnv^!1bHA+HOctG#x4 zZvNW)dEM)_=O13PzL@*w?28$1FTcG0j{mjiht4KNtKL`LmBv?%xFFcmIW0s~8>FwlaycuVG%#K83}IV=e1N4iWY(oSQk$aOHCS z=GNsg=6%MS#@E7M&;L`PK)^xBS};NQg=eB$xk~i9WR_@(pW|pV_q?z>pDlruPmixE+C(j?&AKbsvzPbM3|GN9z|If)^RX@vpiT!l( zQ_IJkkJCTAdEfOu@m=1#yKe&CroJ|Q!~V+X^{E%JuXaD*`|{_rw3l7ad|yUCOMMyn zY}L!=XI!t|KHKza!t>78PA}Tt@V=b)R`Au0cWJK^KS;lI{UrH5>x;+7Ro`ZO{_^9= zSA*YTKfM1c|I+?1{^tq9%fIPNHUAeeUuD?D!p*djHJEuh+ZC3x?A>edCveHJo#AL?`M`FXiH}u;QHXZ$yB{xq9{is1W7W5B z-`0L@{<`f8>z5Uu>ppdVV*S|maq0W+4{`5iy|;h+-xe=-%aY-Kse8pZaU?LYf>_I6G-P9|<4t~wq$?uWe6JR1C*yukvOc_Rd? z`ILly^UW5Lkom=dstZTx2t3 z*~&VbIh0ht#pc)f&+R`Kf0q5Z{PVFN7eD9xkoe;Bqxy^M z55uqFKRCXX{5bdR>W}*G9zQjH6#hK(_1a}_x<(xbM7C{ z-*5ld{}p2_{P&ab>c1OITK|ifz5cIZ{_~H8neJ@~(ut&bs#eF@`l z_JvHBIk;H*IK5e~asFpb<5FQi!u6Yd7gsSy9+x(!BG-IQR<7rqqFm}+9$Zaay%}R}dy+$lSBc{#Pb_;4PZ8T2?qF75Zf%y;T)xbvToah~ za~@(8<>XY1f0i>J{oTuC z^Lr0t%dcY$Pk(;;@AQ-Xf9;RUf0uqw{&Vcx=U?Z(X8k<=W$*V3pPj#L_|*C3^T$)4 zj()88SoX2z1M|nz?{hx)RVUvD3Mee&+fw|(!IeE0jX@kjp0`9IS??fVt-Iq=WUFWdgg ze*5~b@B6|3eLwse{C@H=D*w92`198ZCfh$dnEU^pXSw$n9uCh!Jx#qoFX_zCLs-xJal)ED6wJ}nX<8YrqJ{zOzyqCxDZgo^l4$PKZrtEF;S29b*UPx<+u9B)0c91+Rct`vizo?iq-))hf zJokj{xEX}*b50YO&QZ?K%l?b^IO{x~Iu|HT!w{i-S+?pG7~J z{P^qRCl5Ow|9&9yc+!K}k4zsZJz98w!^4XEP7k^6-+9n{ui-(&y}Ad#?shypeAoN& z`Fn~_(;u)s=YPcf^5Ns>uQop2{3hvn@ViGZ-n{pH_3fkf8`Ce&?_$1re3eKvR z>%Xk~^ZVP?f2KblFqr&)#1!;*3rpDlQnpq`YmN`hOk756AGoVH-toTSG7#9p+bxtY z@Lohs*id|%sD$Jj@f7J}Ql+v|vPSak@{<*ID}GRTtQ4VWt$a*zy0V_qd*wbQE0sq| zWh%PLD^yC9uc+)$ey(y-Ez2Auq+>dOY9wO6jS=3)jc@p1M2?cs%QV;lpcp6Ysyg zz3-0VZO_{eZrR;hc&urh$d$H>M$ye>4+TQwo<@~_@qvz9!UwU6z z{wjSh`QP^Q0pr8pGAz&kO0hlqzm0t%<37$?%r)GrS$TPz*)#aFxONLZ;rS+P!5=O5 zLvXdkA`vU;nc^p9d!@t_lx3}y`sKM*)+%0C{ihtG9;r4(y+wVg`Y!dY>ig99tM64m zs=iD8g!&fslj`f#PpB_fU#h-9eV=Nddb;u^wf71yRifoiDqWU-p&%l;T~1oeMdrQG zL`imm&tmqx2_hU^L4tGGx%stO`*^~bu5-3C#B;R&Q(-gybBUSd*Dpq~AIJXJe>?YA z?2F^?Wgl1m@O*#!>!UZCpKrhV^}*~#)!S1~m0xu}R(u}wFziXneTheF@BF>D?bhWx ztT(scYP~LSqy75i>npFXx!!iY_4@4VZP%AwZ@a$s`tIvXuiv}gdHvb-Y1emLKX9Y( zdeZH%8=?11ZgD(Zbm!P(uKUZMc|Yub+5WiY&F81#?|oiae46W$y@gH;F>;0i!ybYAPDHm3h^_DBe*GlwYIVB2%xpNm4?7l~|%oukbQSD}kkA z{=7~iI$Q;US?ss@)LGy01T*n*$ug{CkN)?JHSNy}=B8i!8KZs}{kQwZ{MY8o*I#m< z7XEnl-um0*w@sfJUz>eMd%^$q=~KT~wvY3lpMP-uNyNS955L|1bnnQ`f;)4s7u{^T ze&KrS_3rEK;CnHJ>cKy}$4>#^y=e_NHWBJ`P zH_IMy-zj^%`d;0$fQN-I*FDaA^Z%*gd#e{8KJI;$^X2cG1>d&6OZw^Z@x|}+pC$ik zeA~ew`csv8+wVM9zki$A|1sR<>|qw=;blwX%jP&EaF6SlkTUO0k@Nf!;&+6WNY;wl z$jq0xDR)*nNb!c;XJsiRQMGmzevMyhpfChsSa?=xL~2xNcxzN?$Y@k*NNYrD{86vc z2-3Ks9;5L|)k4EarAos`v0cqX?x^xkX$GYU65jHyqH|^Jg?c4<_~(ha@+gRiaXt{( z!ghr(pGAoG1>+PhdxlLMb^kipLjL?`mikr3`1t$Ve>c9)`Sb5{)i1eE4nMxWpY!$q zTgT6_z$8HP61kihrW_Lf}#P)0_9-K3Z@$|G~}MJMUe;8FMG_#+{qrukX6C z=K8nm8?GO?{_Xnj>xZu2zCQQ*wd<3wKe*m@o$JQR>zX&_-q?Tr@vU#yf8X77^Xdbx zJE@QD@BMu0^kDi6!$)ba`=98&lX`yaL(t30&s$#K{rcst!VkL-|9(Y$?)uCBZQuX0 zA1O?wzdy2g{wrh`Wjw{Xj(Icp1J-@KlQ`D#i*nZrn()pQzQDgsG)ic#_%RVtsYT+; zWj0AM$xoM6QS?;^S3awhpn6+HS^d9S6gkMLkQtQgx>ctMY%zc?yi;Uu9KAT%^SXjU?3g+(j$7^M!tJ ztmc)GdMmhEe|F*Jh{S(RL{A<d+P^V)UitFTlef=iK1z5p;Q{BPkbC|2_3k*|U3e??_Qe}qH>X^GaJ}_9 zu=uYb8d*na)^b@rR@uQT6fyTN<+_w|$an{S#u zO1<6iB=N4%^DFmvy|j5G^k&Wz;dfQfGCo{z7}m-`@GY;Dg04>CbEaZ20Q( zf9j6}MwZ`I%q4$&SU3N_%C?ZV4eQ=Gn3!%R)%Xo?*E%yn4FH9f5e){)8+T&06*dETgqjm4&Eyp`vH+64K zyuRl8*6RnZU%Ae7W8-y>8y~Kx-?)CA|Axwqgc~|H)Ne4~WV`X~miLW@JDN96-&=B1 z{^63_iH{H7z4=t}fzJ!SM^9euf8zaC>N(#B?pG0?(%&rja_8N~@2;PI{EYw7_~+R- zpZ}kJrZcMi>1Uq&ub1@>LqEGMb1r8yDzHi^Y@d!=70ILUb`TPw1tPFLQlc1<-@LscVPqe{b2qe{b5BU8gl!%f3eqe{bH zqevr6!$RYsx`@UtHBpUvRey~#<&)}Ciu!7|<-RIM%9JQJNM4hBE*36RD6&V=R489u zS-?(o9dC-z74FFb5?tT;Zn5|CSh8_(r?Lof8ZyPQJ^X)}S^2LRV~uTQ=H==zH5FRpLD{_6Vr>x?&UUSE4X;O4&Tb+>(PRNl3} zdHlZq?cWbo?+QLKzi;=f^}wS?^|AVe``ZU|DQVRC5E}|)0x+EZe;z)t;I2gPm=qrfHrTp@F#vvvE4#z zBsxUnrFTnQlRY4vpzuV_PU(_@rOIQaPPMJ7chw`+t2Jyisx%5TDm5%Lax~<@G^h;D z*9g||(x}oXR+k+A zrN6p9E&0y!!TC$pTkVfGUbVjqeDVGDx2G*Hiyyyu_TYi@liByKK1{u%eLwHkjyrd6 zthu%1`nnsf*H>Tfxjymw+Up0e&$)gOd@51v^-gd*;`sHp>*hDxu7AGOcKy~JwHtr$ zrQTA0_~cIhI7E1M=UL)uY{OVUAP zyI6qGIT0IyPeMX`KLnU~xcS+*OnINPi*g@h-ORC!*^+HKqY#Vc|BZ}oe>DHc{<8af z_xqY(o?jn-XZk$wB+H0=Fq3H*f3TXuIBYv+cU`?GxAi?{?lyyMN>M z+=njrem>s*K;hZ@NBu9BJiYSj_wzGv*1xoWFYu=8qshC)p9McW{c86~k70++$n8lW@gDm6;sHQz7wN{u~g zRT`qIm(`_}HPtc|Q&f871C-iiLlyo@eUkku{zz(u=zIw$VQaCC0<(mR`8Ej}@|@*Y z=K8^F&!NZT&F0D_&!We%f$;k%{}Cf{Cvcgjt(+dr?rxzT!kBeZQc=X%@q zc5wP#dcE!XgzKOi&D*a3ygvK-xf^ZQlWsk|KK0JZo0smf-C=uJeDBXA{|AOo^B?&? zZ+If}QubN>>$(>Q-nP9u^8tVU0*JIxcJTf^Sd8OUv+Tt)yrnh+rTL-Fo{QAxSqdFY>&_tNprFJGU<}@H%~8WiWxtxJ(qxrw@}5e)vb75C z(xr0uB^YI{i9M0rDzZd8O~_ePLqJLRCht{&h1{)tDVzp8>g=*yM_5ETzA!ejrvJBQ zKJ{kG#5qE50*lqTXV11z|(Ns^)7G=7}UR*1->!9?fRDMZP#aA-*Ub6di(Xy*W0e!-R!x} zdHeMZhPy9svD|04`|`n(dmN8vJP>}m^3l#`0Z(qfnE6cO^|u!j-=@6Q`@r+={HJLj z=6&`4%=4q~>&{;>KkELv{a*ZE_b(Sy+y62aPNsis^I6_=HnG3qiR8M-@5DPxC{Eyj zsE)`wi8}Fv(pRNe!4P}NbDP(QC`qw!h2Qo~jQ6rP}PuF}YZvO%E> z5do3r8r2$t>hc;{syo!el;^0-Rpd~Pm*1-(FWV|NQ94IfLsCw9t=IyIB9UM*b)mz; zfBDM=H}e|v&)~M_<>hSRTE!;FF^MIDZ2{v%=70ah89V>X_;>7Q_ivf+M}F9TIseuD zydzy!Lrh^CI@;%BQi<3Lk4e;eNQ|;huY?_qW}daJT!G_3iyP^lr9Y-vmvq zkkSHFGJr5BtU)4>zDL`2wi~=RR^JT0IpH?bZRxv-ch24Wd6)O0!UM&}A0IM3UGZ4- z`SquKFE2bdetq|4^IMHKXWtjU>;L5UQSJqgqO*;e4PF4_tVrD*Pm~B_4}pE+rl^R-z&dw__XY! z)7PLcB0qS(ul_ajr|DnMKmGr={(Hsvg&~f`hG+Rkml}GiE+Eg`VjqBjE2rD)GH7YgC!R2qYMv_LA zhO|bd28TwKhN4E5h60#=uD(!RQth5BugX;g?<@r6u`D z;;C4x*awkb5j){8LPrIB1!nU(@m2BU@Eqs7#Hqv{%U;0pn5B_XfywXRr+>P?_xv{a zG5Lq%*SfF%pC*2id0+jW^Uc~fyf44J41HGp{N7`;r&$kAK9am2_n`hx&fU^m9=G@3 zXusJG9)GxY9W)-%c74b7w(GC1w_X2oed2ZX8*SHRZnRxDztMJG{>FmqudiRaA$8N{ zR?n?{x6SUv-xa?1>E7Y{n;tBEIPuZy#~Ys9f7bm zexCVd^Vh_0aX)x}vi&~td(~gxf5i+B7z&v;G0U>?vt8yW^jPJc5i{L>~ z5z$i;kHxP_Zj@hXj@>V6s@R1c_is_a#LrX;5FKw+yQv;0!|-!jdz9@5vPizGiuR*8QR z-zl0Uc2anX$Qr>2p+k83|od-w8v!Rs||7QDFha>vt)&m10mKWTZO{4n6&l6(K}q}@Gq>&&gY zH;&zSc3tj9>-9y~TdyCw-gf;cG!1uOZ@WJA`ikrKu7giebqjgW(pB#Ml{u%$vl`sFk+WK1e?U%PR-=}>L|McKtZ1$1sUoK`MKxFTpxSgbUX5SsRT`PlFb&rLwJ|{DM3qJu zR7_jLQRAt4wYsOeg&Kp}W0grN?#hRiHY+M9UXfRqeF^2c!yZ7 z=oFDcVILuW!FT-2`I346ax-xEaQ1Pivh%V%Wyxcn#FWG^YSedjla zFE>BCemwV~`rXgBZLeRwI{m`zh4!JbM3N!-GfncHi4`NBvIW?VekTw-|1| zyYcz@H*kxh=X%@qx7XXRFSy=zebM!{>u0XFUEh7Z?fT*Cr?0QR?s{Y1b*7uIuS?u| za>M(!&8=N`KHiqTS9~}5{;7L*59J^7JT89x<;jMpanGG!e0sU>)wwq_-_Cen{=xc_ z>gQ8m=6rSe{^t9KpKZT(|B3uN@xKX!Gt*usAJ&DeOF15LT;zVmeVgwVUy-1Tps@&x zh@rT-_!7x&k_1w(hz`#DyWX#3IEqhw%?X(!b|_g@2#=wd#BE_t-Cd zUkX2Rd_4co{oTyhOJ9k_*&;Dc8?lUwM7@^&Qu{ug|-_`T8SpShrrEbbZeC{_8WYw_QJQeKNS6@%qN+>#Vn) z-MD@0?9J3WCvMx^dvW*6{Z02jK7931?@8g4GtVrZuX-u`D*nykHy-aFz5o7k{-^9O zcfaI+|L|S**P35de+2(}{^w&*V`5=eWtC$4!v3CP5!Za~iM$K>TmaKL7%t6^navkzD3TcX=N;1k%l?_z&)%K~KP)}CBukNdnrctF)q7kCu z4Q@5ZfcknGRT}CVl^Sc*jWpQROVv-SUQ(T-QmB%u{8PzEsaVlU(MaK{{6RT6IUU*0 z(iYO|B_$=*#Z$$CMB;?^2tF4$&-aOU2lqOzTO23YPq8jwImuMYc=!Lbe~N^9skPr<<6(Bk69nqziWAW@OAGi&6oTydY%P6HF~o45&NUE2Y>I+xfgiv`JFj; z?%vY6optlo%{ey~-1vI^+x3svPh9_b{q*(C*Y91Qcm2lo)z{x%Uv>TC_2t(OUEh9v z*Y!)+_g-IjefIU%>nt|}Z?NCAx*2+F!Y%PTC3kr5&Ak`@K<(kVM@t_&KGl8p{ki1J z%vTk!z1|wXEBlb}G4*rymu=rZfB*2);Wz8w;D77?vofYJy{K9ml(m z@1ua0pqlUz;j^L{Vk;$XNpMMfN=M7im7OKet*}?|yJD&G8s$pWFjZ&urE2UNd(|s7 ztTZaYBbjy@RT?T9l^Wt2RT|&a!!%?yay9b7X&qFWRBGgE*lJ8wzoX`>7N)vO<%F`7 zvWZf@V!6UB`CPd>vO8p6OaGEuE!iuPCjL`&n@EuG3BhWChkOOR$GD@o8aPbYqgbyp zmolwmko+I^_wsL}Up7DYeV6z);mhAo=^slz{Cv0Wt>+ts*9Tu#zA$;d_vz&)+K;C{ zl6%m1|JL1#yQ+6S-rj#R`liE;-`7`NzjM9q`mO72*9C91UH^2w?Yi6zPz$aN+_ye^ zy$zhATdyCzzTo=B>uungQ{u+V8yPpNZw21&zGHE>^q#=|ga@$?FFxXYGX3eyXVx!V zU)sNBe-rgi?0wY7pijPEV!v8`Pyb=_>))@Xf4Ki$`X9=e$9#}^DeDS07ml5rN!-;u zetb>*;ew4qb|U7Yf5i@oS4z&1`X#+ZrdsxboS*zuMFk}hWk=<6Dl=4ms%=wK(0Btr zQ#?+?MeogzN^7;4s&=2Zw+utU zUFX~DZ{NIm=jPTM@i#PX%)9>m`iATBH`=a$yWV!4^+wxusT-}=_grtk{^5Gtb>o1Y$dq4Xl^^}<(7 z?+V_&{h?v-$mxhC~S_KM7Gc@FvQiad(V%HqnAs!Xca)E=oFPzQ|@ zRcT0SRBD`6uhPiXs06n?ZmWY*ca=u8Mzw~kMwLdqy0%8G8n1egs;sKD@&n~Bik}p3 z%a_SNm$@PPLu!?DzeI$jqL_mC6XE+J(*-9Asq;_aXXSaulg#;)(~IptTOsobmIB6n zM#XCfD6@4s#Ry!NxrhxU(l@0{O%e=YRp!i(Q8!=CSXp7m7b>6%Ax zA2mNX@nFim9rsS%IdwvNzhUv)^dDe*b#wb>SQB*LiQW zURS-*cHQkp+x6$y+pcfCe(1XF4UZfCH<#YDza4j*|E~4j5BCc0S3lhI(Dw=d6Vc~Q z&yKyce7Ww;I$@pW_SEVmGKiIwp|Ni#V=il8w_KXw%e_;+~TF=JA8qGO_ z{U~=Jmo?uZo(=(Pem!9!q4%QeMP7-Uh_gzSOWMoKly;KaDJ!P%M*gp2mXftftIAW= zCu&omeH2lRD)9I`s0IX;8sF8cG}tsM!E+&B)T=ePG^#XQG^#Xqs#j{|&_^O627EiLg`@{i~R@teXiA|LqQ3KsAd@^f)>^4PM6ar&?{ zu`OV%WZwTTli}cRoxk^gDE@N)X7#=DbMKdvAJ%>pe0TZX^w(l({ThUH=G8&ull^uM6COw4XU| zv|WFC9aN@3`iOt7w_UHhDR86h_Q#vw?^@rfeX#Hz|Kt1zXP;<2zWv<$nagX97q8xM zzSjQ0`)=ar*&h~sefoLEkFVc8{Qmos|G&jw38qqp0#;V$_w4IgeYu!9_ww3szv5@) zD-_BUd@8zI*ia%?tVBvta`SoqrH-TFht=HFHZ@oSpJoX7uuLx zUSE5C-VOH~S8rC{^tjD&`{|uWcSP^E-jjPc`$7HV;KymtW)rx z_u=%r(9eM%b-tbZeCtQZx9Z=4KPUck|5MNS<)0YKIYu6KKbB5TQFa?1eXdG=8Qyv! zRsloNKA}+YJkj@(suG4W;Zk2@AIKyt@X4zw1u0%uUZMO>b)jmtdXBo8MxKT{cuWIQ zbuT&6H*g`#xfvj7Q%vz)^%Nng!O^zt=I2fZ@d2EdfRoL8*SG=+*o-%_SV50 z&UdEX%D9(wC*?uoy=9LcJkWTW{#f$Gm#2!a%U-a)d-96mqv_jgpSeHW{`UXV@t+63 z_WjxVW7hvozh^TY{kN3$G2=82Ce~_h0S+%dDekud;e02Abp&h0)(Z1UMv5(w{vvTt zwpaR_yn@^l#X^NV>u%C(&3`jk~@UV#VZB&iu~jK zBdEq5#2?C0z@x^h&bgQIAnSsEAxurbKm7CfvEjGnSB)RXK5qLG^X~V@C$G=F`}(5$ z^`obXFW)`xeD?m~?Z+qXdpvBv`}|(eZOyy8Zg$^xzhQg}6l(3)&w~3bkT7n&J`p@_ z2MYJz>*uexU4MPO@A^-0xzc()`9|CIbvN6tTi^bF{r;WSo6qi5-&TLvaQDLFlKVZ+ zZa?gP`TB|a+t%kgAGlsU|D^C%;@gh*=YJ@Dn)LhHmzDoIzlSik{QAk<^mh|m3_~qv z33EG79h(RLCyorkBJRh+`g}9Q;sp{V)(eG7Ig2iqVUu_&_fg7SQCe0)d6s;Us<=|K z`f_CnjZ3Ok8t0&;H7ImJAq%3bG(hvLpm7chjUsT5#zvz`qeVSTW0q=&`f6nvHD;x+ zDm@Cflt0K7C|#48px`XESnjd-1epd=b*XBhcj62A3q^nPbO|YNUFA1qf6c?hGM#fd zg9Mwx-&CgJpWOcozp4G1{F&t^&xglfCElL;RQT%H`{3saZ&y93f3@YhHo zC-hk0uHVBwx7F^?yP0xV{f5h(i`Uz3wOxOE12nn;Y0)jZ-hX}F^>x=*U0-(n@%10q ze_j{6vGMxK>r267{ok)c#!nAiZ@bQZv+cUV?VcN6cgt>Fxj*|(!lQ-vc0Jktu;Rsp zCl_A}Jnwxc`s%~S4R0=g$$h{5`}>bMzYcy``Pbn`0VC({ugs4Bey~+AN^>c*H1njf zSMzV>Y7x4`n<+X~;ETAOaIVy0(LFLU5=-U&NC_x<$$nDukYA+IrFcLsTG>RyOSMwN z2t29;3SC$a92VL>;Bur|Lsz3p!&0M4!%w41y6gySFVG`Qro~~_~IL_*Wcc3yT0YN*v-OwXKwX8_<1Mn@q+v3pQSwFdu8)9_3h#3 z3qF*;I{W#>oA__@-e>)s^l9NAhObZl?fpKLG3Qqli|Ahq_PhUGxK=VfZ{ok~d)e?Y_xZhd=1=q9ct5`Wvhbnu^Yr@%pLE`x@aWj> z)ern`J-#P<^XDD58$P#Nuiw7ecD?ULE4UZ)5ZsDxyZ-BX>-A&TTd!}s-hTbw^&{7J zU0-*7(RJw?ZP!(Awq38em2{)=&hwiU_a@w4{=oIF=3|Td=bj#Tc>9IHlj7IMo@Kn- z{?hD|$D6*d9q%-M?E4V$>+h!}e|o;=|4;c*%*6ehn|1r&8SIh_id=q7dw48ZKk@Hj zcMis$56bN|BPg_kgJS`XsevPgs!5V^d{x0vYo2=3is4EDphIJs8ng}Q-g#e zETmyHDD<G^U25*0Gn zMORA6il|FG7C0oT#+M|#mHVy0Q;uN1SFEz!@l3WH1^=sAmH%vDy7%Mlf7WmJ|1^Jk z@l)&lg>QFWKluFag~UhAXKC*yJ%0AO{Grav+xH$kdvoXRlgYPNJ^FaF=fU(FGwy`gsF-SykoFI_)<{lj(f z8&|KtyT184>y5kD-`>c*k#XzQO@+HpZZkf(eAn-h_=E7L=8s%ogg#+;J@?t4w|8DV z{2=u@=d^6zsE6h{Cdf}{?B{1hW{R1I!qG0Pgy?jPhh_! z6wLKV^a)R@#6A8F>Fq)ma<4?H<>!jORs1VytRf_HL3OfRyZSYSRE^_GRT>d0RT_s? zD>Ycut28dFSA$z6Q5vAxmrC#`Drh7X)NfDF$kou)a8{q7E~vIj?TL!6YPJ%qO0>ci zr98P$3YpUW@^X?ZWwpegOLK{GO4SIR6!+n87Omxx6kf}@Lf{RX5}z}RH@6X^5y$qw zzgV%zZSKE@&y#+Od}#l%;mx714_@+rR(NjzLGQ`Yw@iciri3+x0KETdxb<{&W4>&DQIZH>X@*e&gkJi5uPE-ulMt zldkW(e(d_9>x-|?zW)9C;p;s&SZ{p2@&3k?TW@aK-7&n)fA8I$x(7$@$vnRHfbAL6 z<2NtTo(jC_dA{e}lb1Z7y5C&;dhcD-k6j;^{9f}V@PEelI;QepKUnMjhI6(v9OcPl z{>3lKwohmUhpuQD_Zx9_zSB}^0vxipg(~D#MB^1L#rG+tOIoRfNpDsSmYt(+FJGyl ztXQS-U#Uu?S*23rtZJ3UQ?)7$P*1T0Tw;UD5@ZZX*+0~U)g9G?R1?&gRU}k(lo(Zt z6;3F1%XKThm9bE8koJ|UlvpMcE4EeYg0Qnhwt$q_S>El!x4C!(C$Y2f39=mEa$uOw zp8Lm{rSyjlWAj(nf6G3d`IY-_-S-EtzJ9TOq4%lv>4EnXAGf}J^sw=@{)4qI_ulJ$ zE_8R=(=E4eJdnhzLnpi!pUa!Bk{(8;Ljn^mLICA~%^*!L0 z^||X)t{;N7vR7Q6alPyM>g#i^_ue>t{qN118`o~HzB%cx!|h4;>+jxslz%_@>7Iv< zFAAR=e7*FU>pQiVkss&2ZvUeC?(p}UAKv|%@|o}7l5d<$-aqA7o&OYaWc_F4`N5dP zw}nMW(4Bp!@LkT?Vv~6KB@OuvWOfPemD?vQqo6MKTydSmJLL|k*D7;m($!ALol)PZ zkf1R`sY*jnB}=1MHB;lFT9w8Xb;wKrXrwPlqgW$A!$%`pBTXYf!(YQmW0Sg$#tyZ& z>MK=!)NNG!)OwUORo^PyQ0A16Qu;14P2q&p1UXZQR2fTA38@gF#o`nAWksj+ED)N{ zd4oTR?LN;trvIFW|3796__Lb1^~W-XH(z%DUHb9KuUYSme;jyS^!4hC6Q7KprF`Ih za_{Z_hcjP)yFc$`)4j&$JMTxXU}1kb{(yT0=Jf$K}JgGQ~lUT?j=^?Jwk{nsHgHcPKxxxV+hVLFty{8)aje^Qt{^J?cFQQ5vyIRT`g_t2A;{t29ojRch>0uhOv5s06QDD$sz; z2EXJ~RRaPR!H`a2v?8*lqQwz@U%p}|eo2iI=Qx%c@- z^xc0q0`9Qh%(*Rmv+EZB%{w^=VtCrzndX918&ycOujkgX35Q2H??jt z-Fk5Q{H?`z8SXUQx4-NAaLc`-$Jr0wJXLw5{zCJ~rdOHIR=%D3V&;bvuUbCKye<3| z^Iq)ds*jt0^L}yoSNQD?gTPM-mhRuD*?9gLa-LzZ;&EjD$G3uYx*!V&mxw->nOH23 zi9|QwW6A3R2c>TbNy(lQiI)2yCaZ8!B0%w~RF~38nN`ZMax5x0sLrd2sChgZp>xzrO$RKFBWS1H(r)~D1N>2lj+;9Uu@qee|P=3|EJpLcfV!6YW%bPUdmAXb3fD0-+Zip z{uZ*!F`VZNVzS^VWwGPSV&fMm=5P>l;kqr%!J{bphxfeL7JdZ@b3t9n8A66qcZ7wc zwL~9D7m7`lStIT!dsSk!>?6s=vJa)E%WjvRBpV=8C3{vTNmfACQ&v#cK=!YUgzO8M z|1wu)UdSw!$&+D_k(Hh;Z70PpEib7hwNgAyvRJH9fch`@R}| zn*aUb=UqQ%e!c#?@H^u_ou60!Gynd|`0|edOa8w#tVRr{*`G4rvh$n(g ziSH46A%7(2B7sd@I|V;-?-%;VQ!Q-FmnRa-FDF_kuwJxQFh?v>=(U)saJo3F$N};5 zA{WKGMfZ#Iimet;5t|{tP^?*eo>;DUlUSs9nV6G!f|$0rrkH^Ebu#G=aH z#OMX}XXZ*$Sui(d`KR>>C{hs`l|5wbH6F)>gFa6H@X~j2- zk5j)|d|2?MGua;Yrju?z2#l-Yx8#rudlw% zd)@!G_I1?Tp4V1y=f2i|yY98o+x@RC-d=tk@b<;)rniA_ZoGZ@Civaqw^!dyd{_9s z}kzf*or`7`a;r@yCv zyZq<=yNSW;pDt6?{|@G543}88FurHK&1A{W%#zK)%<9X@#TL%>j?I93Kl@|uUXBGk zp`5x_}b@C;q#9nfz6JNBOw;Wcf;YgLyCVH1a6(baH2M zH*l@yTF7~db3VsAj_K_39E;eD*t=Pc*fLpkSlyY`SZtZpn4K9Fn5-DY8BPDQF!28S z_V4H@W zas8I~<-*s;pYy-Ae-`~}@pE>9hW)rJr>^E&8nW zY29b7PlrDHe0ubG;wOPGpFa72N%*|z%frtMU-P~sef{v|#@B|g$=~e0ef{?P+k)@M zzQ_Dn_Cxe%!_V74qkhf(rTIJN_qX4Qe-8b5^{4;uk-rW97XC~8pYvaa!HD4w!#9S7 zjJp`4m`82X?B7}Mvo*2iu(7bdVr^zg zVExK`iY0^DoaH{#B4#h9|4eHbGnx1puP_!d7%|@bKb67dKNG|5e~tf*{xSSt_P6ez z&R>y#+x{&0>+#3r@2lVU{!IKm;g92Q|36=UiTqjl>-O*XUkiRK{wnCYKhyuI|NZ{2@^AS6Gk;hA7y0+&fAl|BhK2w78SeeN&LHt$k}>3e2xHg(UdHYJ zk2600|DTbM!Ia6EA&)7NVG&a`!*!+w3>?gd7z~+jFhnrFW2k0kVVuD%!MKK5i*X0D z5#wHFBgTEqMvS|eO&K>b+c3^&c4h2h4qz-{j$@2x&SP|EZeY}A?q}p+p22vRX&&Qx zrp1g6Ov@Slm{u{$GHqmh&A5eeJL7i77RKF-0gQVXWf%`IzGOJexSin$V<*Eg#u$bZ zjHV2y83h?mGk*Mkn(^xYGmLxxpJ80_{|w`d{}&j$|6gHj{C|hB5_BWM|96bJ|Nk&% z{^w#!`!B@I?lh*$j zCYAp|OmhF-n56z&GKv4!ViNr?$t3chl}YsfM@F&#HyOqM?`M?!zmQS>e*>fH|2Rh7 z{}zm9{{`2%e?J*y z|9xRF`}c++=-(rTqJK9SCjUFbu;bqWhI{|EFmU`|!C>%zE<@D+Nes>ZI~dmfuVc9O zznJ0g{|p92hByXShG2$F1`mcF1{;R;3>x@|7RK3{eR53=>Ipy>Hj&Hdj3l=HU3v+D*JE5l>Og^Dek`;Q_z2Z zCfEPrOjiG6nDqW9GAaE}W0Lrv&LsFhlZo?xCKL1jG$w}sNlgF#MKb;S=f}kO-+_ts zzX22Xe>onzcRf4`;me9-)jcRe@_?;{@rEp`ge^X z_1`&$=6^>R7XRDLaP;2>hUfp5GVuJL!C>^ihauvB14G^aVumIE(-}_vk7D@n-<-Z(L_sOo6aW7?OdS6g zF|qt#%=GWyBBtN}7BKzzH;3u_zZpzF|4m~0`LB!V&%b&m#{b1kZ2yy)xc>(;3H`Tc zlK!v7r21c&N$>wJMvMRV7@htfWc2yJh%xkkGh@R4B*u*Y4vdBWc0=e_W$Y(NB%Q0T>5|e|HJ=V|9|-3_n(0w?LR+*-G4;}@&85) z@BcY5octHeu;gC~L;b&EhS-123|9XpGD!WK&+zB(I)(>-cQYLQdy-+@->VEW|2|}B z`}>-q?C)2GjKBXFV*j!-hWzDW^!qEw==E2G(etkuqt{Vp#rfCd0{p6Byq9YiAJrU&CPbzlb5`e=0-M|44=v|9u!P{HjRomj8Phi~rwcO#AOh*6Xm^A0bkb z;QulPqyJe9f&b$eO8y5hO#Sb~u;af8!=3-C3=9k+3~CH44BiZ%{}(bm{Xd=I%KtqK zNB=)!*!G`|am9aa###UU7<>O0FgE_5z*zQw9b?Y_Q;f;~pE5@M|HBycUy#Z3zY>!( zIL_>uEdF~knfwoAGW;LTr2jvfN$-CQlP;K!VAB5|%4GQ8m&y3QE0gJe8zxJz9#tmC z|6)uo|2ddE|Nmt4{r`+H@c(7T@c;W6WB#vVO!_~WG5voPW8VKn#*+VTjJ5x@7+e4I zFi!aYmSOh)^9;-XuV>iuzn9_Q|7?cy|J@lL{8wan|Nr-Y28QeZ1sFE{S77M*Z@`fL z-+{sLzdwWQ|5%2f|8f|v{i|Tu@~?$q^1lfT#s6k8g#BB>VD)b`gTlW}3>^Pt}N)flJ#cV+DPpTyYmzmBo? z{|v^m|C<;K|DR;c{(qM-^Z$FswEqlDN&oqn68=jvCHz-niuh8ZssH~lrv87+nDPG}W5)k;j9LHpFy{PU$yo4z zDr3?AM#i%LnT*x{gBTnCTQau)S77Y>&&oLc|4W92|Iae4{l9@>$NxzT$NrZvT=^fy z@aVr8!-xOk4FCUs`_Id8?Y}g`w*MLoQ~#SWl>T>O2>tKJVDdkLLF|7r!;gR247dLk zGaUF=$*}BS14IA6R)&gy9Sq6;x)}og^)fj8o4{c7Zz6;0zljVo|0Xer{+rCe|8Ft_ z=fBC|I*s+;R4~o)ZwdqVzbOp-|E4gA{hPud`)>+^>c1%r`v0adSpA#K;P!74L+HPW z45|P68A|{4GIadwWSH}>m0|P0MuwCBsu&*rD`EKcFONawe=39i|5yg^|A7qY|6LiH z{#!B3`>)5a`@bT?jsL<7-~Y2Qh%)^AZ^H2Ee;~uX|9K1-|MxN+|G$c1*Z*S-8~;CI zSn>Za!@~dKjI;miGfw;O&N%6REMx!wBF5hTt&H9Or!jW@U&h$+e+y&B{{xKe|4%Wt z{lCQ6_Wveh+yDEFt^Xf0w)}s>*bK!@{~s_m{lClD`2QAT8~2;hF$-!G3@z&hhg{sM+|%ZzhKz= z{|&>Q{~sCl{Qu0b_y2c>egA(l?EC+hVgLXC3bxm;pYFV40ryYVtDv}FT=C{>lxnsU&Qd~ z{}hHF|63XU|1V=;V@PMwgD^+W+ zeFpvix(s^%wHWmNt25~TS7k8xugGBhUyi~2zcho*e{lxq|Dp_@{{i<9gxBvh0f6D*&{}=v$`G4L2NB?*Izw`gZ|7-uR{J;4B;s4YB-~B)S z|L6b1|JfK0{ug4{|6h(_-+xVpz5k6F_WrkJ*!$m&Vc&m$h6De@84muBXE^*no#E*J zJaD~m;(sN>$^Q)ur~kJwoc`a*aOQt6!@2(x8P5No#&F^PEQX8!=Q3RWzlh=L|0N99 z|F2-U`F}OT?f>f;?)~4y@bLduhNu5`GQ9Y|o8is>Jq#cI?_v1ze>cOA|GOCe{NKvJ zz_5{lm0=YF7sC<;UWPdgLJX4`#2LC6q!}6*6d1}GR2Xs@G#Qc@^cf-;j2L_w%o&^* ztQjmB>=^VIoETIYTo|Mo+!zEH+!@#y+!_AaAQ|6Lhw{C8%!@ZXW) z#D6=61OKfVcKx?x*!3?B{!vA~>1^>Aj^8d3jfkFaOv7fAYWe|AYVC|L^>t`2YI<8UL^Rpa1{D{}unw z{NM2Z#Qz=tkN!XK|KR_V|M&gB@PE($oBwzHfAoLH|JVO_{{QlS=l_5Icm8K**!`c6 zVefx&h6Dc<7!LkdV>tR>kKx3BV}_IeEf~)Jw_~{Y-A(FwJA(+97!H2<*!IdG9!JZ+Q!IB}2!GIy0L6aeZL4hHHL6RYYL5LxOfr}xG zfr%l6;rIVQhR^@~8Q%W)VR-T10~}Yk|2r~V{cp=~{=X%|>HlU7$Nw8K9R6>}aNxfl z!@mDo414}-Fzo!V%CO_V62p%Fatz!5%P?&JFUhd&zZkgPzwJLC! z{lD-3j{m#=@A<#&|H1#8{vZ3l{{QL!YyMyOzv}L#<$guOjI>WyIx(tW@8#5gJZ^3Zt zzcs_z{|*e7|2r{U`|rwd`@b8*{r~O^kN(@cO?y!~6g43}61cG5q}R#_;#Q zD+42g3j;fY3j+^>3xg1YGlK+!3xh0!GlL3)GlMpR6N4dx6N5Q}1A`rdJ%clYErSPx zHG?066+;k%1w$BvDMK`a5knktL%Pygrs|N4K{|L^~&|Nr@a>i<9gr~Lo-fAW7O zhROd~7$*N`XPEMzi($%tK8C6P1sJCP7h#z7Uz}mye<_BA|K%8#{#RsJ`CpY`-G5Dn z&Hr^7cKkPB*z@0n;n06`h7;XR z|E(B){43-SM3=Rxj3?2-u z41NrZ3?U4E|3@+W`k%n?{eK!b$9?#p&+z7dF~iIMWem^%S28^LU&HYDe;vca|Md(H z{x>k(|KGrH_kTUZZ7{B7xc$F|;nx33hMWJ(7;gM8VYv3cfZ^)@9EPj^(;2S(PhzI zpW*s{PKMk6nHlc?|NZ~j|F8dF{(txX!~f_1zy5#l|JVN;{}~xB{byr1^`DpF$bVsm iz5gW`w*Hr6SodF!+nMe4CfeDGOS>j%dmi97Q+mNMGQ+A7BMVhn9DGep@X59p@X5Bp^~AFA(SDA z!H>a@A(kPI!I8n9!HB_}L6Cu$;p_je|9}1e{hyJ6fq|8QgW>Q0U;khIfBXN*{|Eo? z{J;PI;s1yKAN;@f|Ka~<|L^?2_W#2Fi~sL}#cuyU`~TAaegAj=-}isd|5N|Z|KIR` z?f|5^W2{>T3h{~z@~^MBg^n*R;|TmLuw@BH8YzxRLV|3&{7{Xg{o z$p35qul#@Z|K%9vj5Beulm30 z|H}U>|1bK#`2W)XOaIUMKjZ(L|MUJY{J-G;!v72ZPy0XP|GfXR|IhwE@Bf_tv;Hsr zzwG~t|11A5{=eY=;{VJ4ul&E{|LXr6{%`!h4h+}+-|&C)|MmaZ{on9^_5aoXxBTDq zf6M=!|F`{L52la(Km7mT|0DmG|6c+&>G=P{|9AaA@PEVqHUGE#-v&1C)c*_rFZ@3X zHg)I!?f;McKlcCf|Lgzv{@?L``Ty1b&;LL5|KR`Q|Bw7X@P9wpUwi+b`G5BR`TrOG zpZI_L|DpefzwozF(En-w z)Bl(MFaO{EzwQ5w|I`1^0>{ME|I`1^{lDn{{QqnIul#@X|C#?!{@?lk^#7axAO64j z|NZ~>|KI+92j``K{}~uK7}yzj7`PY&8AKT*7=#!k7!(-v7}OZV8RQrw7(^H}z$s9J z!GOVlL7PE~L6t#+L6!H7YZ zL5IPBL7zdFL772`K?|%~i$Rk?gF%r&g+Yu#n1PppmqCm{f`ONTi-Dg(kbw&F-S5Ockq`~=2ib0A<Gk1fI)yk z3>*$1n?)J;8H5=`7z7xE8AKUG7(~D(D=;WC2!V4fF9SQ+r66B`*qjW^42%r_{{R2a z!0`V+69Y2?4+A%YAcHW&pZ~xAvoo-Q{U^x454MY&fe&1^2`~tO-6hQ+$DqNW#vsig z%OJua$RNj{01kN-1{DScaESnNtt`~fsthU&>I~`($_(NRQea;xf_)d$RN!i!63z8z+lMWz+lH<#bCqW%;3V{$Kb`_%HYo6!QjE*%HYD_ z$l$`@z+l7R!r%txdog%1I5W61STL9}m@!x|STL9~STR^K*f7{L*fLl!I52oHxHH%= z1Tw@hWHTf))G;(LOlFwIFpFU-!(4_%4D%W0Ff3tM!Z3$n9>Y?GCE)sF8N(`uO$=)o zwleHy*uk)cVHd-0hSdz~7}heZU|7nqieUr8T86D)*?kO`87?rKXSl#{k>L`<6^2U; z=fLn1!x@HS40{=lGF)Of$#9Y3I>T*-8w@uXZZX_rxXW;k;XK1BhT{yk8SXLMXSmC7 zmEk(W5r)GI8yPk-Y-ZTVu!&(S!&-)w42v07GR$L`!O+0a!qCdl%+Sx!$I!&k#!$^r z!I008&yd5A2PV@QG8y6-5*Z>Hf*Cv+0vS9R92wjge86F6$Kc3d!(h!|$zTo+8FK~` z1_K5o22F5kS7eX^$D;Ja@b^q)BH~nw?-}b*9TxK-?Z~0#V zEh(VNrn}LUc6C949 zl8=pniveOc8#sS*GH@_}@-Y(w8v`3S7lZ2SPyfIE|MCC({~!Oq{r~v?>;I47`uzF- zm;aytfAs&+|0iJIKKy_G{{wLC3aW!2fLkZG|KI$7@Bgj;*ZyDof9wCv|5v~vbm#w# z|9Af1`+xKQEpWVn+ABBzgG4XJ!cUjKjn|4sil z{oe_OJO1y6#`Nm{8~?8b*OcqPtTq2ZHS8vE*}np;bJhP9|CjyW`hVO1o&R_J-}-;^ z|6Tw0{NMe57r4$p`v1uP!~a3#q5u2-9{|^upcuaJ|2(*jaN+;C{}=yX{tu!r|Gxrm zTipb=GOqu>`2Ql|8M_4|9=BcA#eYG`~USnD4c)){|RpEfy&U&|G)fa1gCZ;22gp*&A<*$ zH6R+4ULYke4+9Sa9|IQysDx!_;AP-p0F~9C@?4NXh(Uxwh(Uls5G)2#&(6ROHtYBQ zAOBzffBXOK|2N=x1GOm5|3Cl#%KuCM@BF{}|Hc28|DXPU3~oE!{eKIbwn4G^>i?Vn zPr$x>{{QL!hhU$-0=LB8{D1fV#s3%o-~505|HXe$Sb*{-sJ{L4A5<&+`p?TC!XU=L z&j6}*6d2?fAoY$2IHmJ5a5IQ9NPeDAcjB&Uj~10*n|9O$6(Lk%HYW0!Qjsj01iPv1|J3waJ+aj_%L`exP!wR zRKGfbX9!kj$Y8=? z4EC=fgE0e043a*L84MWo7(lWh8q#jmVbEdFW>5yZL=oJY0);;_0~5oK|3AU?!`J^` z{{Q^{`~Uy{pxnX;ZZm;es-SY>!~a+RUxCYscmLn~fBFCQ|BwG){(t=cB{=_rN}Bio zzy1IA{|nd*P$>;6L%#k0^Z!4%+y>Q^U%|F~0hi>U{@9!UZ~x!_fB!$IOgr=c;l?hr#`iUEsdUlK)G=y_^aECxPqK_Wzy#yZ(3nZ~ou(zv_SO z|GfWM{}cYF{!jiN_dorA*8i&iRsWm+H~en~_cts5m;bN+U;4lJe*?HCZvEf&zu|xN z|JMI?|I5MptN&O3Zvcx`{?Gqk@;~E$64<&KU$z6>=L6L#yZ>+ezxn_6|6Bg=`M(cbD}ee;pt=CmPXyKd=fE}A zX>h%E`~QvqcmLn}e*+9}{=f16D!6`o^#AVv$Nxd)<703M3aSgA{J;PI_WuX}pMrbp zAX!ko`SL%gPI&YG&Hs=8KZEsi{}2B^ zLTi!t;L;XUqku5Pj8FeR{eS=e)Bo@Pzy5#s|K0y@|3RuiwbPydH^FrmsBSy{|JeUi z|4;qD^#3xrFMQ?y)&J-JpZmY<|8{UZFZ#dW|C0YJ|8Mxe>i_otd;ahJzZ=|7-1&ds z|NY>w0@c2to+YUL1FAm{{XYzD9i8}p_Wwz63Ic@%sE2*yKd5F0h0X2%_y6Df4+#-a zy#cB{?*4xOu1lW$e-0ig0F7$A{SWdvsBVI!bx02Q`2XYo58xUVBnqlOpMz^sPz&oP zw9fqb|Hpq2`2GJkxW-{(`2Qc&%3@&n_y70*U;n>@#X&jl|9?;&3hJ9EGRQM%FlaOA zgIoN%45|zY43Z2o;64hd_XNVAzKIlr7=r+~ZzK%v7lB$x>MV7`z$$7!nww7{VCh7|I#)8G0Ee zF-&Ke#?Z^q%}~uy$B@mC!4Sm|4X#a0!8M;bLnuQqxI_h&SC$M`46Y2W44@Jb)W-nz zFF@l)&J3OmCJZ_ZatvzV`Z1TG5Ud8&5&)H7pni%qgB`g1vVxicDrZ5i1J$}9Gh7)w z805iYF)Cm?{2BZhKxH)q}2xACmh+yz#@Mf@Juwt-duw@8l2xc&0Fa`ICL16$Y{XwO@6@x82&iQZ>IWYEf9QYO|Lp%$|4;b8>i^pRE&p5p`~LU%AN@b^f9?Ou z|I`0Z{|{=t?)ks-KPLkxgFS;YgE)gA!x?aE_~!qw|G$CTXs7<4{J-M=3UH}c`oHA= z^8d^KAOC;qKdATO^xy0M-2YSmKl}gs|B?Sk{-^y<`LFR`^S{D>rT?IDyLJCJ{eS!a z_5XAK&w)p!K;vBZ{y+H7#K6EH#30Nd#301*@BhF5SN~u659&XG@-e6%18RNy{P+H! z@IUGQwEt87$Ni57kLP6k&-g#{|19tr-&xBlP$KmY#% za32BGpJrlUWB`>p6aG*9AN4=-e-zmF3ICn`yZkr&ulL{XzvKU;|4IK#{ulnQ|KI$7 z!vDVibN;K*Vulzp`9*3Lqe;Rl+kB32sL7YLD zL5xA1;qCu-;E_LW23`hG-FN2y8Sr=@s72w=;Kd-yAj|Oe|C|3O{-6K9`TvIhpnhWS z{|Wy=V`-rA573C@w*Tw@Py9dq|4wj^Ve9|7|L6U0|KAL*%RpmcyZ-O}|Lp&h{~Qcl z;9A|C!H1!kA%`K3Ar;(q;9}qckJWwCYKC`ip*u3TGJsk* zpz$YD22*g`)D_&?1+}O_C5r>N*urpy;TpqphGz^97#=cgXV}UxpJ6e>0*2WP^B5K}tY%mV9?t-cVO(Um$gq!L zFT-SpDGbF7#SHNbu?&R_r3{@6tzgy57?v{ZWZ1!Qgy9&&NrvML`xy2!Y+%^HFo|I@ zLk&X}Llr|U11LON7&;i1Ff3$P%&?T9ouQQ>gdv>4o57PIo*|y0lA(&BnV}I}_NFl; zF@!QiGUPC1GBh!?Fw`(qgVp$fOH>bVdp3w6gdrN*mIs9;sF&f!;KdNj5Xq3lkjfCo z5X@l9;K*RkV8~$2;KTsxlYq)QP z)%F+vU--ZM|MvfD{;vV|!v6gK3vLyF#(6>G&inuG2iKvX@o`QD9tKdq@y7q_|3P^Y zG(yhKzy%)HVrO7s`27F-|0m#Cg6IE1EkQ1D4}hBi)N%xkp}hP5{y%6057eT(^ZypO ztpOT20F4oV${^6FJ*bBVYJq}A5kO;$pt|?^|7-uB{(tr#G&Tcjd7l1%>i_otJO8ix zzZ~4gTK#`HxNi;WT_60v4?Gs|`v3F)H~!!Lf9n4Ua2pgfE|69Q02cSMWNDMSuzU2Rc|HuBH{D0^Fo&Sga?+1^lgWA!P{!jYv_22J* z@&DZaEB~+mKjr^~{~`ZF!L8HT|7ZONm77lgo&SScuWkR^{+s=`_%HKc^1s)A-~Y+~ zlm6TNxA|}O-|~Mkcton;zsY~g|1tl={?GZp06e=A_&@l6%Kt=gYuNw4_kXqjn*R;J zBj<+yW&g|nXa3LlU*f;?fBpa3|9}4b_wVGt!~b6Xd;gE&|L=b<|GoY9<==;YkN!RT zclF=JfB*k6|2O_`@c;k6|Nox+d+|^BKhJ-u{|f(=|113W{_p+Y@W1i@yZ>(g+y8I> zzpMYQ|NH&#*FUBIivI=w3;*BqZ`Z#q|F-|r{;vTZmuLUa`2W{GrvH)u1Hi59{QnvM zJN|e7U-^I4|LFe_|Ly)e{NMb43wZ2k!T+WIi~kpZ$JRijEV>Np4Au;`44^tNptxJ`f6o7J|9|}lwL3s<^hN*Y z{onF``~PkKxBWl+|J45<|Nns93#$7;c?#6V-Ti;h|Cj$?{s-kdP+Wt?bwn6M89M*B z|1bVu{Qvs@TmRSoU-3WYf8zh8{}=tg`~U9$S^ww!Fa2NiKkt9R|BV0X|11Ajf!ov| zzj^$3|IhuO`+w&DjQ<R&B{6acltl? z|E&Mt|Nr^V$H2`18d(F4b6x#^`9Em<9@HlUg%oJ6ujqfl|L*@C|3RZ9sSI%pEB>$f z-}JxvKWKJhCc`{%`2^}8gT^CT7+M%|7_u2c7{b6K)aDEp3|S0W46O{U44`rx)RzR! z5Tr0*Vi|1wF zVrXJ$WthS+89bJ&!Jy3$%Mi@~3P(_X1k{HI)jxU+n&43j(5T|?|9}63#<4)-ev|)C z`VR_Y&?s2L|N8%@{-62J%)r2q_rLIe;s1jFEDX#HpmM17f9LT5%`k(#( z26$HN_WzduE&pfzpAMcg{qg??c!mws{*e4H^*`}{Ja{~C*Z&>=7ybv0^e+5A{r{Z* zOa9LTkMn}&!$32FpuR7totFJS6P!OmG$`dA_<#6+!~dH9TmJ9(|L6Zt@N61rZ1LRx z^Z&d5cY(*9@BP2~AJhf`%}^Zyr<}w8H~-)C--N-C!J8qFVb}jH|1bQ%^1qv*lOc^E zg8?)y2pW6HXUJs$ji0UlzYaX=8q1KtP{B~a5XBG<9$iUbh+*hp=wzs6XkbWUh-L6* z2w-SrsAD+Ha0EQR9M2HP5XBG+o?k9ws9^w&VS!rVu?(>c*$n9nwG7P+@eEN6vJCRz zafoaGFN0?{yZ*QR-~NBs|Be4Qfcw{=e*W(NJO8iyzyAN6|8v1(;b*}8#jXGM{@?Q- zG$sg|;{uH#f##bpJt|LDIzLny;)h9wN!81{hY$?X}O7(gRf1q@{jdlm0>EwKZZXH z){J(HpBUaVHpXNLH>wjh-2trXak2aC=G(#9LW$5p2-81d!V{}`~S`V z=lx&uAJnG>ttkMF>}38={D1NP&Htd0ws3|J26qO3hIEE_hE9fFhA9kF8J03EV7S6? zli?u4eui9z9EJ{tE(SJ6Hbz!PX2#PDXTWnsj~MPTtY_H5@R8vigEgZS<70*w3>O$q zGk7w3GwL$xF=jHPGng@0GHhko#30Ei!zjSW%Xom{2*Wgn$qX!vtc+re;*9nT_6(r$ zWRSgw8TK=PW)qhGU-2JQ_g64fGJxiGw*B7%9-9Zvz)$->_5bAmQ~%feul`>Po}+2} zANt?_zv+L=|MCChz~w;3|19uW5NMu$`u`dKC;p%Cf9C(0|3UN7p!y6nj@0$P=l_oX zJN|>xiYbE?!><3^{!jfs_5Z#9kNzL~zyE*n|LXtC{xA6d`TsX?c?xR#<}&1hXZk>6 z2%tVP$bIe%?%=XapTQVBzxwO{pZ}o##bSne44DkM3;_%Q44W9%GhAl4#sJF4pm9l1 z-*-8~Qij6}M;V$Kni$d;(il1!Iv7B0jzoqShCYT#3{?yj4BXIpQ&794kf8(|jyo83 zgWKJpIUrCu8pROF@ZtZb|DdtsDuz1nNOT%QCb$d+jjw|GouIJ|&{$dL|F-|2QDjgb zAJo#W-br>`mK=pU*{|N9pm8Spo|3UVE%J|~{ zh2S!H(f=j?cmLo1zv+Mb|Lp&1|4;uv{~t78{owzD|DZY$)Q2zsU-=(Y_LVVIGo&yi zFqHkT_#gj29z0tFDr-P(jy{Gy26+ZKaNV=_|IYuQabb|ZK=sSl|G)qHFt{?PF@VOA z(Es57H~-!K_x0b$e)|5g7N{V(}n{lDtJ>wlO3 zps)wc+k(b#C;y-Pf9wCP;NCZA-AUK~{{No;9sh^^kNe;MzvF+*|9J4Y1E~M8;Qs>f z7zJoP3)Dsc%_DC6zu|x5|CaxD|Ly;`{%-=Wm;#OYp8kLIzW{gz0jR|&%JBaG2k<(n zkN-dY&t}MGSi!J@;R?fLh9ZVy@GM*{LlpxPBP%1Q-x$OY%)rga&1l7F#rT)u4}%e- zF{2ox7$d0MyvA^g;WfikhNBFp86+5m8J!qi8DB6wV|c~zn!$w8nDH&c2Zm`36Bs%e z`WdD$^fK&cIKuFq;VZ*=hI8ODcp}3@hCYT~h7$}Y8J;pcVF2~BL1VR`KFt$`mkikq zsSG*{hTwj5CPNxS7DGNm8$%;_b_z5)37Y8z&GLc9f*$|B|DTURfFXk+lOdZSlL52} zDDi&+xK9b1@d3?_*8Xq!&&I&a0O|{YW{N;#!KeS9{(tiS>HoL?U;F>^|A+seHVUY{ zs>Gne0ID}Y{%>Gt0MFc9`hVsB-~T`VgI0Si`ac^yHr~Y0zyN9wwEl1Z4=OW3eX4o? z7ypm{ANBv{|J(mT>s=VZE7|^n(<^B1$B!X|;s5{N|797Z8G;xh8AQM)2rz)kx-^Cq za1VY3!%7B625W{F|3CgOX2@h{WawgGX87~pfWeeu2E!DFI))~OMGW&8?lC-KNM=X? z&tpe0M1%7)Xib3~gEd1k1E}WDVVKC!!?1{91w$f3EQ17t1OsUHs*It4;VQ#zh8GM^ z7$!1I0OW|14>Z5T%D}|n!QjKNiD4~6GD9WFjRoo4uaAEXq*?cHU^Y0K&u`=^9!Kz2DBbx;{X2txBuV$zx)4=|7HIx z!Sg3&|I7bd{kH_q?1(T3Gl14gfzrqw@L1u$|11Bm2CsGl&7k-G?*os?f%;yY47?1W z_Qv}E8~^|Q|NB2^ybrX}4%8L_tyBE?|NDPXJiYq=0o+aot&ReX)`9xWpt)jW@Tjj2 zcuh_YLk>eXLpQk11M01}GxUJ_@u2bYRp2~%jbS;%67ZM~XdDPM#@x!##&Cw=6nNYx zk|7#A`UaZA^kQ%UkM(?I_`&d-;Tw3Q43xf}{eSi!RClN{C^2|3_@ujmsKcNSUWWu4CzfH51g{4L&BcM{ zV?eVHA`BAXnMipCMeuql(2C8&;5Bwf!1FJlwTfS$vo@eLilDWMPr)lHo`PrL*8N}e zAJi9&_#gd0=6}@x*8i>Gb$Xz60-*j8XrA}&|5xC(3ZS{X$NwLI+b)0p{{oNS-1~p; z|Hc33|DX7O`ah_B4_Yk>T9*LI`=HfipuRvaLm$IJhItG<3=HjkS)&48}PyV0z|G@v<|3NFnK-_-)-jq8Q}5grff>A7544`{)c>>Kyk_^`?!V=KoBw(LGyj9;)i?_%LG1|8JP~M)22?hdGZZrTG6XYdF{pvZ(LiHbpxI@R+TH(m zgV*kY+S;JL-lP8y|AW>PW-(+l^fB~+$3a2m6R0l^8s7(v?@j+d8$6e~{QsK&+x~9? zuU$R!|I~lboFXU>Ff%YSa4>K%fW|>e|Cj#1_V3z1=YP)szWn|2SM{Ijzw`gj{oD3` z*Z(?(a)y%(7Z|26OaSMTj{hD1{r>y^FaKZif9wBU;Ca(4|8IcT%YxSBg4SJu+MK`t z|NQ^x|BL@;!DB=Z|7$QPFk~};dWpphS`0c2puTwL|4wi^1jTDKLl^_7o{?Y>W_a=c z^?y(u1zMK@8XX4BQ-S6yGyiA)5B?wYzvX`$c;ydh4Krw6?v(%2|M&dw{12Kb2KCWF zYbil1Bthf%%nYEB5GIBr|BwH-{crid>|gc2Cx7q%z5e&s--dsU|K@^bOBm)e2r}|A zf<~x8Yc0J0`~3g)@7F)s|FYmY^b7wlgI6_!+NPkjWuVo#H~-%N&$`z9uL6%dfbzwZ z|5L&3=Xiz$hH!>Z@Vw;1|2M(?x)=W+gX6yJf7O54|Kk6d{8j4v4L7^@f;Gc0GA@_*w0 z%m1$YJMr(-zlQ%c{}UNf8ICg?0ncs9|5x}g{ZI1W&A)g4HvFso*Z+Uw|7wP6hHKyz zh}Rg-GF$++kDM8u83P%E7~e9yVF0B|P}`9EKPNa3fyV1X8NwN+F-&EsWvFEUu|cyP zpmC3d46_+J8TuHU80;B9tM5RoEI?~-<})l{n8q+2yrvYiHh}Rz!+(zd9RK_N_x=Zs zQGwQ%g3>0aOkKvXoZ%hAdxj#0LIxEERR(Vc4+c2~6^8x)cl-yfx=UqDV&q`rU}|D) zVcf*Hfw7*sfjONegXIF#IVNpJUB(Q?48}W*w-^~1Sr|cO0cahu7NaJkGLsUM3X?k1 z2Zpx{ptaO||M~ud+M$Y!a*W-eyzrm#zX*dQLpM_g({_gK3{8Jp{;c}D@~H z9OJq_EB^TX4gOojT+Td|bvi3(#j5Nd**`T5H4HOYXRt~!OEH7y&19KmnQED9m~Z~Q z_4n+rv%gpvnHl@pCb0D~_AzSyRQcKVtM6Ana|Sa9D;Mjdzt8?^{ZalS%p}2dgy{&= z%|F-vO!__bH)sqLG!7v2PyFAKKlA=v{CDl&LZ*34wJdcku1s!BM*sBxIs9?`v;E)3 ze=01REFW3lu|5FpPWbiU7ieC1D%%vcI))mC3*WAN+x30p_gjA+{;~dL^-JuV(6{x! zxBTv5?PQH)i(~`M9SJb;F(q*&bIoC!!xsEK_XN;w)$KD zzpj6>Yzl0k} z9b$U_`}1$6KmUGLanx{pjI2kP%K3H=u=<|^h&_@458>8JUhTK{$b z`@;2s%aqfcbN%mizlFXCe`)*O_WK&^RaRp*6Sh=_REFh$m;MFKRi`i~G2dXk$(Z^l z`Oon`r~at2DzUC)Tg|5aTkSX7SGKR9HLP`<^_ME^=Ru~UOiWCSOrUtW%5syX z=4-{*`Ck`(J;QO5BaJ(Yd-9J7KSX|r{4f$T6Js}EG{{oTQ~mV&^Y24<_S~^~>iSgj zr{GVSPx7A>o+v*t`KbT#Ip-@*KLLM%ZS0%at(feYI$2v;*K=>+?&qDvE5$9sy_Ibb z+obJ%tdD-V{A8vf;|K9g~F7sUGQ#_}6HuA3Ly}@yt z<2K6;78Q0i_8+`IcqMqnczyo(|6zFc@2SddmD_C(J0AZ3@aMz*Uk`s>|9S1_%%8J< zb}@G_&l6f8^i<)g!fve{TG|>08pUFHVhjw-4DUX@{p9k+<;#+vi+`qmN&hnY^^Dh= zpLIW9W4Xk#iDNTIHbX9h6{8vBGrm`RS(0gz(}iXWiTxA$7yTjjL(-SzFJHfX{3iWe z;W^hWwp(*v%zfd?;?DAg|0DlRj$0g{v8-QzzyF=XIFFH)m6dfV`(pM6u4XP%P9x3} zjHemr{h9q|%D?IVKzj)e|2pz(_UBojZ+yJ@@$|P-->&?<`d5@um~kq@bcQJmlNcNs zT^PF=IvCXdYyP+VYyQ{hr^(NiUsil^|Lp!b<9p_JCq@Ux6s`=e7WO)JwjX>y65d3- zvG{2F@#Wtae-|;D{M-Ak=bin1`}?~0_3o>_S9w2|WiHDnfzJZ3`CjrZW?#nc%<8~;jO`>_16wT{ zAG08H%D=>a2Yw#xYofW(yXv(k8e~RHegVbly&oW;WzktRZTe;e~6u1?* z7qTy44`mHuea-TLWhKi(mPw4W8K?d3`ffL>2B3Adix?I&Y-icRqQ?09!Eu@MBBu?z9s7QU{R}sM zT>o+7^R3UPzMTAW>({Mc@r((KcbV=masTJ}U--M=_sZX^e#iff|7-Hs`0uCRUw%9P zar{&Fzu|u@OAJddTQA#mrkPAzesBD}{>P>t5x>HJJ^S_KSL~0ZA9KG?`yTx#>CYmT zxhyr@_1sLn47?T`mK=tR28_%8uJ~*CPxoKIzp#JhzYBl&{h0K_-0{p$YH@ngg$WY8!$~cE{79(gZ;_=@{e>wkf z{$cyW_Gi|=+5a4w?3q3>e`Q|7w2}!l;sIKl2--COn%nnh@MW0%f7*Z9|I*-fZ>Jc~ zGM-^N#iYro!N~oe|Nq?oGyeNB1u~sxKE-UzWX!bt->!eTzYBgx{S5z^{4?ce=I@-} zFaA9E)A+mhcg4@#pPs)0em(m8;4cp&H{)%_yNt^imM{qa7y0k-&+*^Izvurp{b~Ng z@r(0U+V|w|pT2$jmh>y`SMdMP|JxY1GlKeC+W++aHT`M)BlK7BFKG4dMCN{GK30C# zv&?6ik24%+*!6GcKTv#HGMY1rGKw+k|2O#m{_oqrasT4~fqH`_|BL>+{CE8i+T~%z zY|cELc@8rt6ARM|hSv=GjQWfXjCG7(7``(oGbk{CR_Oit_y6DCKRf=M{B`!%?cXjvECnpvShlj1b5w9daYb@1VqeVO$Xw67gK;rd9-tR5`xEQgp6F|)I&>r+fA0Lr{-66Fv>$6L z!#0KojQ1EPF-&E+{P)7&h(A$(0{{5`sr%db_xb;Pj&bJS zxqtWk-uZjQpA~-${u%w_`Oo$L`oA0hME(o^U(L9daWV4(=19gE#`1rq|4jay{%>b& zV{~D1VX|PfWCZP{0gd0UWLn1bi1{(|EvDN{;>=>q=b6tjS2NZyO8gc7yY$zxU(

>v3* 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/default/link_visited.wav b/assets/sounds/default/link_visited.wav new file mode 100644 index 0000000000000000000000000000000000000000..f59819b237a469eb2b3d0a1c77f2c4f37130848f GIT binary patch literal 19244 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
e8DvPo&;>zDRoi<-O6z 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�dv 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

Q}W08@6A8Y|DXMD&gjax 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/default/stop.wav b/assets/sounds/default/stop.wav new file mode 100644 index 0000000000000000000000000000000000000000..3ee4420b50cf484af0c02ae0296b0313c1130e10 GIT binary patch literal 384044 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⋙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ⅈ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�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

c824`~NooUH`*Q3J0yB2>l-e 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%0A%Gzri~|`m7y=p6 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�g!GO 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

After control

+ +` + // A large document must remain navigable without exhausting the live + // two-second first-capture budget before any browse position exists. + for i := 0; i < 1000; i++ { + page += fmt.Sprintf("

Unrelated paragraph %d

", i) + } + page += `

End of page

` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, page) + })) + defer server.Close() + adapter := atspi.New() + adapter.SetCaptureSoundGeometry(true) + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + command := func(args ...string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool: %v: %s", err, output) + } + } + command("key", "--clearmodifiers", "ctrl+l") + command("type", "--clearmodifiers", "--delay", "0", server.URL) + command("key", "Return") + var document atspi.ObjectID + var applicationName string + for !document.Valid() { + select { + case event := <-adapter.Events(): + if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Dynamic navigation regression" { + document = event.Source.ID() + applicationName = event.Source.ApplicationName + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.SetFlatCaptureTimeout(10 * time.Second); err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.SyncFocus(document) + move := func(command review.DocumentCaretCommand, want string) { + t.Helper() + if err := worker.MoveDocumentCaret(command); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + got := result.DocumentCaretText + if got == "" { + got = result.DocumentPosition.Snapshot.Name + } + if result.Err != nil || result.CaretErr != nil || !result.Moved || !strings.Contains(got, want) { + t.Fatalf("next line = %q, want %q: %+v", got, want, result) + } + if result.DocumentPosition.Snapshot.ApplicationName != applicationName { + t.Fatalf("document target application = %q, want %q", result.DocumentPosition.Snapshot.ApplicationName, applicationName) + } + t.Logf("line=%q elapsed=%s capture=%s reason=%v", got, result.Elapsed, result.CaptureDuration, result.CaptureReason) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + next := func(want string) { + t.Helper() + move(review.CaretNextLine, want) + } + next("Start of page") + next("Counter") + waitName := func(name string) { + t.Helper() + for { + select { + case event := <-adapter.Events(): + if event.Type == atspi.AccessibleNameEvent && event.Source.Name == name { + return + } + case <-ctx.Done(): + t.Fatalf("name change to %q was not observed", name) + } + } + } + waitName("Counter updated") + next("After control") + next("Avatar") + next("Add content") + waitName("Add content done") + next("Unrelated paragraph 0") + next("Unrelated paragraph 1") + move(review.CaretDocumentEnd, "New content") + for _, unit := range []review.FlatUnit{review.WindowEnd, review.Line} { + started := time.Now() + step := 0 + if unit == review.Line { + step = -1 + } + if err := worker.ReadFlat(unit, step); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + t.Logf("flat review unit %d elapsed=%s capture=%s reason=%v", unit, time.Since(started), result.CaptureDuration, result.CaptureReason) + if result.Err != nil || strings.TrimSpace(result.FlatPosition.Text) == "" || unit == review.Line && !result.Moved { + t.Fatalf("large-page flat review unit %d = %+v", unit, result) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } +} diff --git a/internal/app/browser_events_test.go b/internal/app/browser_events_test.go new file mode 100644 index 0000000..72b30a3 --- /dev/null +++ b/internal/app/browser_events_test.go @@ -0,0 +1,1049 @@ +// 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" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +type autocompleteRequestQueue struct{ requests chan speechRequest } + +func TestBrowserNewTabsKeepAddressFocusAndSuppressURLRewrites(t *testing.T) { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 32)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 32)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) + }() + defer func() { cancel(); <-done }() + expect := func(want string) speechRequest { + t.Helper() + select { + case request := <-queue.requests: + if request.Text != want { + t.Fatalf("speech = %q, want %q", request.Text, want) + } + return request + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + return speechRequest{} + } + } + page := ownerEvent(atspi.FocusEvent, ":1.7", "/window", "/page") + page.Source.Role, page.Source.Name = "document web", "Original page" + adapter.events <- page + expect("Original page, document web") + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "t")) + tab := ownerEvent(atspi.SelectedStateEvent, ":1.7", "/window", "/tab1") + tab.Source.Role, tab.Source.Name, tab.Source.BrowserTab = "page tab", "New Tab", true + tab.Detail1 = 1 + adapter.events <- tab + expect("New Tab, page tab") + entry := ownerEvent(atspi.FocusEvent, ":1.7", "/window", "/address") + entry.Source.Role, entry.Source.Name = "entry", "Address" + entry.Source.Editable, entry.Source.Autocomplete, entry.Source.Focused = true, true, true + adapter.events <- entry + if request := expect("Address, autocomplete"); !request.Append { + t.Fatal("address focus would interrupt new-tab announcement") + } + deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "previous.example") + adapter.events <- deletion + tracker.Observe(input.NewKeyEvent(0, true, "t")) + tab.Source.Path = "/tab2" + adapter.events <- tab + expect("New Tab, page tab") // No address focus event for the second Ctrl+T. + adapter.events <- tab // Duplicate selected-state event. + selection := tab + selection.Type, selection.OriginPath = atspi.SelectionChangedEvent, "/tabs" + adapter.events <- selection + selection.Type, selection.Detail1 = atspi.SelectedStateEvent, 0 + adapter.events <- selection + background := tab + background.Window.Path, background.Source.Path = "/background", "/backgroundTab" + adapter.events <- background + tracker.Observe(input.NewKeyEvent(0, false, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "a")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "a") + expect("a") // The selected tab did not steal the owned address focus. + tracker.Observe(input.NewKeyEvent(0, true, "BackSpace")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "a") + expect("a") + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "x")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "selected.example") + expect("selected.example") + tracker.Observe(input.NewKeyEvent(0, false, "Control_L")) + tab.Source.Path, tab.Source.Name = "/tab1", "First tab" + adapter.events <- tab + expect("First tab, page tab") + // Firefox may report only a tab-list selection, followed by a delayed + // selected-state gain from the previous tab. Fresh state rejects that gain. + selection = tab + selection.Type, selection.Source.Path, selection.Source.Name = atspi.SelectionChangedEvent, "/tab3", "Third tab" + selection.Source.StatesKnown, selection.Source.Selected = true, true + selection.Source.ToolkitName = "Gecko" + adapter.events <- selection + expect("Third tab, page tab") + adapter.events <- selection + tab.Source.StatesKnown, tab.Source.Selected = true, false + adapter.events <- tab + marker := entry + marker.Source.Path, marker.Source.Name = "/marker", "End" + marker.Source.Role, marker.Source.Autocomplete = "button", false + adapter.events <- marker + expect("End, button") // Marker also catches unexpected queued announcements. +} + +func (queue *autocompleteRequestQueue) Enqueue(request speechRequest) { queue.requests <- request } +func (queue *autocompleteRequestQueue) Interrupt() {} + +func TestAutocompleteAnnouncesSelectedAddressWhenItChanges(t *testing.T) { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + options := presentation.DefaultOptions() + options.EnableSound = true + options.PlaySoundForRole = true + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil, options) + }() + defer func() { cancel(); <-done }() + + entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Autocomplete: true, Editable: true, Focused: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + select { + case request := <-queue.requests: + if request.Text != "autocomplete" || len(request.Sound) != 0 { + t.Fatalf("focus request = %#v", request) + } + case <-time.After(time.Second): + t.Fatal("missing address-bar focus") + } + tracker.Observe(input.NewKeyEvent(0, true, "s")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "s") + select { + case request := <-queue.requests: + if len(request.Sound) != 0 { + t.Fatalf("ordinary typing sounded like a completion: %#v", request) + } + case <-time.After(time.Second): + t.Fatal("missing typing presentation") + } + entry.Text = "https://stormux.org" + entry.SelectionStart, entry.SelectionEnd = 1, len([]rune(entry.Text)) + inserted := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "suggestion") + inserted.Detail1 = 1 + adapter.events <- inserted + select { + case request := <-queue.requests: + if request.Text != entry.Text || len(request.Sound) != 1 || request.Sound[0].Name != "autocomplete" { + t.Fatalf("completion request = %#v", request) + } + case <-time.After(time.Second): + t.Fatal("missing autocomplete sound") + } + popup := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: ":1.7", Path: "/suggestion", Role: "list item", Name: "Other result", AutocompletePopup: true}) + popup.PopupFor = entry.ID() + adapter.events <- popup + adapter.events <- inserted + select { + case request := <-queue.requests: + if request.Text == entry.Text || len(request.Sound) != 0 { + t.Fatalf("duplicate selected address announcement = %#v", request) + } + case <-time.After(40 * time.Millisecond): + } + tracker.Observe(input.NewKeyEvent(0, true, "t")) + entry.Text = "https://stormux.org/docs" + entry.SelectionStart, entry.SelectionEnd = 2, len([]rune(entry.Text)) + updated := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "updated suggestion") + updated.Detail1 = 2 + adapter.events <- updated + select { + case request := <-queue.requests: + if request.Text != entry.Text || len(request.Sound) != 1 || request.Sound[0].Name != "autocomplete" { + t.Fatalf("updated address request = %#v", request) + } + case <-time.After(time.Second): + t.Fatal("missing updated address announcement") + } +} + +func TestAutocompleteAnnouncementRequiresSelectedAddress(t *testing.T) { + entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Autocomplete: true} + var availability autocompleteAvailability + availability.focus(atspi.NewEvent(atspi.FocusEvent, entry)) + edit := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "h") + activity := input.Activity{Intent: input.TypingIntent, Sequence: 1} + if got := availability.offered(edit, activity); got != "" { + t.Fatalf("unselected text sounded as completion: %q", got) + } + entry.Text = "https://stormux.org" + entry.SelectionStart, entry.SelectionEnd = 1, len([]rune(entry.Text)) + edit.Source = entry + if got := availability.offered(edit, input.Activity{}); got != "" { + t.Fatalf("completion without user edit sounded: %q", got) + } + if got := availability.offered(edit, activity); got != entry.Text { + t.Fatalf("selected address = %q, want %q", got, entry.Text) + } + if got := availability.offered(edit, input.Activity{Intent: input.TypingIntent, Sequence: 2}); got != "" { + t.Fatalf("unchanged address sounded again: %q", got) + } + other := edit + other.Source.Path = "/other" + other.Source.Text = "https://different.example" + if got := availability.offered(other, activity); got != "" { + t.Fatalf("other entry sounded as owned address: %q", got) + } +} + +func TestFirstBrowserLoadKeepsActivationSpeech(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + browser := newBrowserModeController(nil, &modeKeys{}, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/document") + activation.Source.Role, activation.Source.Name = "document web", "First page" + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "First page, document web") + load := ownerEvent(atspi.DocumentLoadCompleteEvent, ":1.42", "/window", "/document") + load.Source.Role, load.Source.Name = "document web", "First page" + adapter.events <- load + expectActivationSpeech(t, queue.spoken, "Finished loading First page.") + button := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/button") + button.Source.Role, button.Source.Name = "button", "Next" + button.Detail1 = 1 + adapter.events <- button + expectActivationSpeech(t, queue.spoken, "Next, button") + select { + case <-queue.interrupt: + t.Fatal("first load interrupted its own activation speech") + default: + } + load.Source.Path = "/second-document" + adapter.events <- load + adapter.events <- button + expectActivationSpeech(t, queue.spoken, "Finished loading First page.") + expectActivationSpeech(t, queue.spoken, "Next, button") + select { + case <-queue.interrupt: + t.Fatal("document completion interrupted earlier speech") + default: + } +} + +func TestBrowserTypingAndNavigation(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 32)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 32)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) + }() + defer func() { cancel(); <-done }() + expect := func(want string) { + t.Helper() + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + quiet := func() { + t.Helper() + select { + case got := <-queue.spoken: + t.Fatalf("unexpected %q", got) + case <-time.After(30 * time.Millisecond): + } + } + key := func(name string) { tracker.Observe(input.NewKeyEvent(0, true, name)) } + entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Autocomplete: true, Focused: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + expect("autocomplete") + key("s") + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "previous.example") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "s") + expect("s") + // Suggestions can clear the native focused bit without changing the owned entry. + entry.Focused = false + key("t") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "t") + expect("t") + // Chromium inserts the selected completion and emits several popup events. + entry.SelectionStart, entry.SelectionEnd = 1, 11 + completion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "tormux.org") + completion.Detail1 = 1 + adapter.events <- completion + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + popup := atspi.Snapshot{BusName: ":1.7", Path: "/suggestion", Role: "list item", Name: "Suggested site", Selected: true, AutocompletePopup: true} + popupFocus := atspi.NewEvent(atspi.FocusedStateEvent, popup) + popupFocus.Detail1 = 1 + adapter.events <- popupFocus + adapter.events <- atspi.NewEvent("object:state-changed:selected", popup) + adapter.events <- atspi.NewEvent(atspi.SelectionChangedEvent, popup) + quiet() + // Explicit suggestion navigation still speaks the chosen item, once. + key("Down") + adapter.events <- popupFocus + expect("Suggested site, list item, selected") + adapter.events <- atspi.NewEvent("object:state-changed:selected", popup) + adapter.events <- atspi.NewEvent(atspi.SelectionChangedEvent, popup) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "suggested.example") + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + quiet() + page := atspi.Snapshot{BusName: ":1.7", Path: "/paragraph", Role: "paragraph", Text: "Plain document text", CaretWord: "document", CaretCharacter: "d"} + key("Down") + adapter.events <- atspi.NewEvent(atspi.CaretEvent, page) + expect("Plain document text") + blank := page + blank.Text, blank.CaretCharacter = "", "\n" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, blank) + expect("blank") + key("Control_L") + key("Right") + adapter.events <- atspi.NewEvent(atspi.CaretEvent, page) + expect("document") + tracker.Observe(input.NewKeyEvent(0, false, "Control_L")) + key("Right") + adapter.events <- atspi.NewEvent(atspi.CaretEvent, page) + expect("d") +} + +func TestAutocompleteFocusCaretDoesNotInterruptAnnouncement(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) + }() + defer func() { cancel(); <-done }() + entry := atspi.Snapshot{ + BusName: ":1.7", Path: "/address", Name: "Address and search bar", Role: "entry", + Editable: true, Autocomplete: true, Focused: true, Text: "https://example.test", + SelectionEnd: len("https://example.test"), CaretCharacter: "h", + } + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "l")) + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + select { + case got := <-queue.spoken: + if want := "Address and search bar, autocomplete, https://example.test, selected"; got != want { + t.Fatalf("focus speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("missing address-bar focus speech") + } + select { + case got := <-queue.spoken: + t.Fatalf("focus caret interrupted announcement with %q", got) + case <-time.After(40 * time.Millisecond): + } + tracker.Observe(input.NewKeyEvent(0, false, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "Right")) + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + select { + case got := <-queue.spoken: + if got != "h" { + t.Fatalf("arrow caret speech = %q, want h", got) + } + case <-time.After(time.Second): + t.Fatal("missing arrow caret speech") + } +} + +func TestFocusedStateLossIsIgnored(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + keys := &modeKeys{enabled: true} + browser := newBrowserModeController(nil, keys, nil) + browser.ready = true + browser.mode = review.BrowseMode + browser.document = atspi.NewObjectID(":1.7", "/document") + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + lost := atspi.Snapshot{BusName: ":1.7", Path: "/button", Name: "Old", Role: "button", Focusable: true} + event := atspi.NewEvent(atspi.FocusedStateEvent, lost) + event.Detail1 = 0 + adapter.events <- event + time.Sleep(30 * time.Millisecond) + if !browser.ready || browser.mode != review.BrowseMode || !keys.enabled { + t.Fatal("focus loss changed browser mode or browse grabs") + } + select { + case got := <-queue.spoken: + t.Fatalf("focus loss spoke %q", got) + default: + } + cancel() + <-done +} + +func TestNewDocumentFocusIsNotRetainedOldCaret(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + browser := newBrowserModeController(nil, &modeKeys{}, nil) + browser.ready, browser.mode = true, review.BrowseMode + browser.document = atspi.NewObjectID(":1.7", "/old_document") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + newDocument := atspi.Snapshot{BusName: ":1.7", Path: "/new_document", Role: "document web", Name: "New topic"} + focus := atspi.NewEvent(atspi.FocusedStateEvent, newDocument) + focus.Detail1 = 1 + adapter.events <- focus + select { + case spoken := <-queue.spoken: + if spoken != "New topic, document web" { + t.Fatalf("new document focus said %q", spoken) + } + case <-time.After(time.Second): + t.Fatal("new document focus was suppressed") + } + if browser.focus != newDocument.ID() { + t.Fatalf("browser retained old focus: %v", browser.focus) + } +} + +func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + where := make(chan struct{}) + browser := newBrowserModeController(nil, &modeKeys{}, nil) + browser.ready, browser.mode = true, review.BrowseMode + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + entry := atspi.Snapshot{BusName: ":1.42", Path: "/address", Role: "entry", Editable: true, Name: "Address"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + select { + case <-queue.spoken: + case <-time.After(time.Second): + t.Fatal("missing entry focus") + } + oldHeading := atspi.Snapshot{BusName: ":1.42", Path: "/old-heading", Role: "heading", Text: "Old page"} + adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldHeading) + // The event loop must finish processing the caret before it can receive + // this command. Join before inspecting controller state. + where <- struct{}{} + cancel() + <-done + if browser.focus != entry.ID() || browser.generation != 1 { + t.Fatalf("old document caret replaced editable focus: %v generation %d", browser.focus, browser.generation) + } +} + +func TestBrowseSelectionCaretUpdatesReviewFocus(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + browser := newBrowserModeController(nil, &modeKeys{}, nil) + browser.ready = true + browser.mode = review.BrowseMode + browser.document = atspi.NewObjectID(":1.42", "/document") + browser.focus = browser.document + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + + selection := atspi.Snapshot{ + BusName: ":1.42", Path: "/selection", Role: "paragraph", + Text: "Selected", CaretCharacter: "h", + SelectionStart: 0, SelectionEnd: 1, + } + adapter.events <- atspi.NewEvent(atspi.CaretEvent, selection) + + select { + case got := <-queue.spoken: + if got != "h" { + t.Fatalf("selected caret speech = %q, want h", got) + } + case <-time.After(time.Second): + t.Fatal("selected caret did not speak") + } + if browser.focus != selection.ID() { + t.Fatalf("selection caret did not update browse focus: %v", browser.focus) + } + if browser.generation != 1 { + t.Fatalf("selection caret generation = %d, want 1", browser.generation) + } +} + +func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + where := make(chan struct{}) + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + expect := func(want string) { + t.Helper() + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + entry := atspi.Snapshot{BusName: ":1.42", Path: "/address", Role: "entry", Editable: true, Autocomplete: true, Name: "Address"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + expect("Address, autocomplete") + document := atspi.Snapshot{BusName: ":1.42", Path: "/document", Role: "document web", Name: "Loaded page"} + load := atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document) + load.FocusOutsideDocument = entry.ID() + adapter.events <- load + where <- struct{}{} + expect("Address, autocomplete") + // A subsequent load with document focus must still announce and acquire + // the new document, even if the previous chrome snapshot was editable. + adapter.events <- atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document) + expect("Finished loading Loaded page.") + cancel() + <-done + if browser.focus != document.ID() || browser.ready || keys.enabled { + t.Fatalf("load did not schedule new document resolution: %+v", browser.focus) + } +} + +func TestDocumentCompletionPreservesBackendSpeech(t *testing.T) { + for _, eventType := range []string{atspi.DocumentLoadCompleteEvent, atspi.BusyStateEvent} { + t.Run(eventType, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + browser := newBrowserModeController(nil, &modeKeys{}, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/document") + activation.Source.Role, activation.Source.Name = "document web", "First page" + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "First page, document web") + <-queue.requests + load := activation + load.Type = eventType + load.Detail1 = 0 + adapter.events <- load + expectActivationSpeech(t, queue.spoken, "Finished loading First page.") + request := <-queue.requests + // Exercise backend cancellation: checking queue.Interrupt alone + // misses the implicit cancellation of non-appended requests. + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.deliver(ctx, request); err != nil { + t.Fatal(err) + } + if speaker.canceled { + t.Fatal("load completion canceled earlier speech at the backend") + } + }) + } +} + +func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) { + for _, tc := range []struct { + name string + start string + end string + }{ + {"reload stopped", atspi.DocumentReloadEvent, atspi.DocumentLoadStoppedEvent}, + {"busy completed", atspi.BusyStateEvent, atspi.BusyStateEvent}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + where := make(chan struct{}) + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + focus := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/document") + focus.Source.Role, focus.Source.Name = "document web", "Example page" + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Example page, document web") + start := ownerEvent(tc.start, ":1.42", "/window", "/document") + start.Source.Role = "document web" + start.Detail1 = 1 + adapter.events <- start + if tc.start == atspi.BusyStateEvent { + expectActivationSpeech(t, queue.spoken, "Loading. Please wait.") + } + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "Example page, document web") + if browser.focus.Valid() || browser.ready || keys.enabled { + t.Fatal("loading retained browse focus") + } + end := ownerEvent(tc.end, ":1.42", "/window", "/new_document") + end.Source.Role, end.Source.Name = "document web", "New page" + adapter.events <- end + if tc.end == atspi.BusyStateEvent { + expectActivationSpeech(t, queue.spoken, "Finished loading New page.") + } + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "New page, document web") + if browser.focus != end.Source.ID() || !browser.resolving { + t.Fatalf("load completion did not schedule document recovery: %+v", browser.focus) + } + // Retiring the old page must not clear the replacement's ownership. + dead := focus + dead.Type, dead.Detail1 = atspi.DefunctStateEvent, 1 + adapter.events <- dead + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "New page, document web") + }) + } +} + +func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + where := make(chan struct{}) + browser := newBrowserModeController(nil, &modeKeys{}, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil) + }() + entry := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/address") + entry.Source.Role, entry.Source.Name = "entry", "Address" + adapter.events <- entry + expectActivationSpeech(t, queue.spoken, "Address, entry") + for _, eventType := range []string{atspi.DocumentReloadEvent, atspi.BusyStateEvent, atspi.DocumentLoadStoppedEvent} { + event := ownerEvent(eventType, ":1.42", "/window", "/document") + event.Source.Role = "document web" + event.Detail1 = 1 + event.FocusOutsideDocument = entry.Source.ID() + adapter.events <- event + } + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "Address, entry") + cancel() + <-done + if browser.focus != entry.Source.ID() { + t.Fatalf("document lifecycle displaced chrome focus: %v", browser.focus) + } +} + +func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + where := make(chan struct{}) + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + + row := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/message_row") + row.Source.Role, row.Source.Name = "table row", "Message one" + adapter.events <- row + expectActivationSpeech(t, queue.spoken, "Message one, table row") + load := ownerEvent(atspi.DocumentLoadCompleteEvent, ":1.42", "/window", "/preview") + load.Source.Role = "document web" + load.FocusOutsideDocument = row.Source.ID() + adapter.events <- load + busy := load + busy.Type, busy.Detail1 = atspi.BusyStateEvent, 0 + busy.FocusOutsideDocument = atspi.ObjectID{} + adapter.events <- busy + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "Message one, table row") + if browser.focus != row.Source.ID() || browser.mode != review.InactiveMode || keys.enabled { + t.Fatalf("preview busy completion took message-list arrows: focus %v, mode %v, grabs %v", browser.focus, browser.mode, keys.enabled) + } + entered := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/preview") + entered.Source.Role, entered.Source.Name = "document web", "Message preview" + adapter.events <- entered + expectActivationSpeech(t, queue.spoken, "Message preview, document web") + busy.Source.Name = entered.Source.Name + adapter.events <- busy + expectActivationSpeech(t, queue.spoken, "Finished loading Message preview.") + // Speech is queued before the controller finishes adopting the document. + // Join the event loop before inspecting its state. + cancel() + <-done + if browser.focus != entered.Source.ID() { + t.Fatalf("document focus did not enter preview: %v", browser.focus) + } +} + +func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) { + for _, test := range []struct { + name string + bus string + window string + deactivate bool + defunctBefore bool + defunct string + accepted bool + want string + }{ + {name: "other application", bus: ":1.43", window: "/window", want: "Address, entry"}, + {name: "other window", bus: ":1.42", window: "/other", want: "Address, entry"}, + {name: "owner ended", bus: ":1.42", window: "/window", deactivate: true, want: "Could not find current location."}, + {name: "loaded document dies", bus: ":1.42", window: "/window", defunct: "/document", accepted: true, want: "Could not find current location."}, + {name: "old focus dies", bus: ":1.42", window: "/window", defunct: "/address", accepted: true, want: "Loaded page, document web"}, + {name: "replacement after old focus dies", bus: ":1.42", window: "/window", defunctBefore: true, accepted: true, want: "Loaded page, document web"}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + where := make(chan struct{}) + browser := newBrowserModeController(nil, &modeKeys{}, nil) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + focus := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/address") + focus.Source.Role, focus.Source.Name, focus.Source.Editable = "entry", "Address", true + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Address, entry") + if test.deactivate { + adapter.events <- ownerEvent(atspi.WindowDeactivatedEvent, ":1.42", "/window", "/window") + } + if test.defunctBefore { + dead := ownerEvent(atspi.DefunctStateEvent, test.bus, test.window, "/address") + dead.Detail1 = 1 + adapter.events <- dead + } + load := ownerEvent(atspi.DocumentLoadCompleteEvent, test.bus, test.window, "/document") + load.Source.Role, load.Source.Name = "document web", "Loaded page" + adapter.events <- load + if test.accepted { + expectActivationSpeech(t, queue.spoken, "Finished loading Loaded page.") + } + if test.defunct != "" { + dead := ownerEvent(atspi.DefunctStateEvent, test.bus, test.window, test.defunct) + dead.Detail1 = 1 + adapter.events <- dead + } + // The unbuffered command follows event processing, without a sleep. + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, test.want) + cancel() + <-done + if test.want == "Could not find current location." && browser.focus.Valid() { + t.Fatalf("browser retained ended focus: %v", browser.focus) + } + if !test.accepted && !test.deactivate && browser.focus != focus.Source.ID() { + t.Fatalf("background load replaced browser focus: %v", browser.focus) + } + }) + } +} + +func TestBrowserAddressBarNameEventsAreIgnored(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + options := presentation.DefaultOptions() + options.EnableKeyEcho = false + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil, options) + }() + defer func() { cancel(); <-done }() + + entry := atspi.Snapshot{ + BusName: ":1.7", Path: "/address-bar", ApplicationName: "Chromium Variant", + Name: "Address and search bar", Role: "entry", Editable: true, Autocomplete: true, Focused: true, + } + focused := atspi.NewEvent(atspi.FocusedStateEvent, entry) + focused.Detail1 = 1 + adapter.events <- focused + select { + case got := <-queue.spoken: + if got != "Address and search bar, autocomplete" { + t.Fatalf("focus speech %q", got) + } + case <-time.After(time.Second): + t.Fatal("missing address bar focus speech") + } + + siteInformation := entry + siteInformation.Path = "/site-information" + siteInformation.Name = "View site information" + siteInformation.Role = "image map" + siteInformation.Editable = false + siteInformation.Autocomplete = false + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, siteInformation) + + typed := entry + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, typed, "c") + select { + case got := <-queue.spoken: + t.Fatalf("unexpected typing speech %q", got) + case <-time.After(30 * time.Millisecond): + } + + suggestion := atspi.Snapshot{ + BusName: ":1.7", Path: "/suggestions/first", ApplicationName: "Chromium Variant", + Name: "stormux.org", Role: "list item", Selected: true, AutocompletePopup: true, + } + selected := atspi.NewEvent("object:state-changed:selected", suggestion) + selected.Detail1 = 1 + tracker.Observe(input.NewKeyEvent(0, true, "Down")) + adapter.events <- selected + select { + case got := <-queue.spoken: + if got != "stormux.org, list item, selected" { + t.Fatalf("completion speech %q", got) + } + case <-time.After(time.Second): + t.Fatal("missing completion speech") + } + // A focus event for the same popup item is another route to the same + // presentation in Chromium and must not repeat it. + adapter.events <- atspi.NewEvent(atspi.FocusedStateEvent, suggestion) + + suggestionName := suggestion + suggestionName.Name = "stormux.org suggestion" + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, suggestionName) + adapter.events <- selected + select { + case got := <-queue.spoken: + t.Fatalf("unexpected duplicate completion or browser UI name speech %q", got) + case <-time.After(30 * time.Millisecond): + } + + select { + case got := <-queue.spoken: + t.Fatalf("unexpected browser UI name speech %q", got) + case <-time.After(30 * time.Millisecond): + } + + if !isBrowserUINameNoise(atspi.NewEvent(atspi.AccessibleNameEvent, suggestionName), suggestion) { + t.Fatal("focused autocomplete popup name event was not classified as noise") + } +} + +func TestBrowserNameChangesBeforeDocumentFocus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + adapter := &fakeAdapter{events: make(chan atspi.Event)} + queue := &fakeSpeechQueue{spoken: make(chan string, 16)} + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + // A new browser can send these before its first focus event. The last + // focused application and address-bar state cannot identify their owner. + for _, role := range []string{"button", "page tab", "frame", "static", "heading"} { + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{ + BusName: ":1.7", Path: "/changed", ToolkitName: "Chromium", Role: role, Name: "Background update", + }) + } + // This focus announcement synchronizes the event loop and must be first. + adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{ + BusName: ":1.7", Path: "/document", Role: "document web", Name: "Example page", + }) + select { + case got := <-queue.spoken: + if got != "Example page, document web" { + t.Fatalf("page focus was preceded by name noise: %q", got) + } + case <-time.After(time.Second): + t.Fatal("missing page focus") + } + // Preserve ordinary toolkit name changes and actual browser focus events. + for _, toolkit := range []string{"gtk", "Qt", ""} { + event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{ToolkitName: toolkit}) + if isBrowserUINameNoise(event, atspi.Snapshot{}) { + t.Fatalf("filtered non-web toolkit %q", toolkit) + } + } + if isBrowserUINameNoise(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{ToolkitName: "Chromium"}), atspi.Snapshot{}) { + t.Fatal("filtered browser focus") + } +} + +func TestEditableEntryEchoExcludesPasswordAndReadOnlyContent(t *testing.T) { + for _, tc := range []struct { + snapshot atspi.Snapshot + want bool + }{ + {atspi.Snapshot{Role: "entry", Editable: true}, true}, + {atspi.Snapshot{Role: "text", Editable: true}, true}, + {atspi.Snapshot{Role: "password text", Editable: true}, false}, + {atspi.Snapshot{Role: "entry"}, false}, + {atspi.Snapshot{Role: "entry", Editable: true, ReadOnly: true}, false}, + {atspi.Snapshot{Role: "paragraph"}, false}, + } { + if got := speaksTextChanges(tc.snapshot); got != tc.want { + t.Fatalf("%+v: %v", tc.snapshot, got) + } + } +} + +func TestSystemTextMutationsDoNotSpeakWebContainers(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + page := atspi.Snapshot{BusName: ":1.7", Path: "/page", Role: "document web", Name: "Example page"} + section := atspi.Snapshot{BusName: ":1.7", Path: "/section", Role: "section"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, page) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent+":system", section, "new page content") + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent+":system", section, "old page content") + adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/end", Role: "button", Name: "End"}) + for _, want := range []string{"Example page, document web", "End, button"} { + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } +} + +func TestAutocompleteRetainsTypedPrefix(t *testing.T) { + entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Autocomplete: true, SelectionStart: 1, SelectionEnd: 15, CompletionPrefixCharacter: "b"} + event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "rave://version") + event.Detail1 = 1 + echo := &autocompleteEcho{} + activity := input.Activity{Intent: input.TypingIntent, Sequence: 1} + if got := echo.insertion(event, activity); got != "b" { + t.Fatalf("retained prefix: %q", got) + } + if got := echo.insertion(event, activity); got != "" { + t.Fatalf("duplicate completion: %q", got) + } + activity.Sequence++ + entry.SelectionStart = 2 + entry.CompletionPrefixCharacter = "r" + event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "ave://version") + event.Detail1 = 2 + if got := echo.insertion(event, activity); got != "r" { + t.Fatalf("extended prefix: %q", got) + } + // A separately reported typed character already covers the completion. + activity.Sequence++ + typed := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "r") + typed.Detail1 = 1 + if got := echo.insertion(typed, activity); got != "r" { + t.Fatalf("typed letter: %q", got) + } + if got := echo.insertion(event, activity); got != "" { + t.Fatalf("completion duplicated typed letter: %q", got) + } +} + +func TestBackgroundAddressChangesDoNotInterruptDocument(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Editable: true, Autocomplete: true, Focused: true} + page := atspi.Snapshot{BusName: ":1.7", Path: "/page", Role: "document web", Name: "Example page"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + // Focus state can change before the page's focus event arrives. + entry.Focused = false + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "automatic redirect") + adapter.events <- atspi.NewEvent(atspi.FocusEvent, page) + // Even a stale focused bit must not override the owned document focus. + entry.Focused = true + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "previous address") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "new address") + marker := atspi.Snapshot{Path: "/marker", Role: "button", Name: "End"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, marker) + for _, want := range []string{"autocomplete", "Example page, document web", "End, button"} { + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } +} diff --git a/internal/app/browser_mode.go b/internal/app/browser_mode.go new file mode 100644 index 0000000..13502e4 --- /dev/null +++ b/internal/app/browser_mode.go @@ -0,0 +1,462 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +type documentContextCapturer interface { + CaptureDocumentContext(context.Context, atspi.ObjectID) (atspi.DocumentContext, error) +} +type browseKeyOwner interface{ SetBrowseNavigation(bool) } +type commandGrabState interface{ CommandGrabActive() bool } +type browserModeResult struct { + focus atspi.ObjectID + generation uint64 + owner atspi.DocumentContext + origin review.NavigationOrigin + err error +} + +// The event loop owns policy. One joined worker resolves only the latest focus; +// a generation check rejects results overtaken by another focus or caret event. +type browserModeController struct { + source any + keys browseKeyOwner + capturer documentContextCapturer + mutex sync.Mutex + focus atspi.ObjectID + origin review.NavigationOrigin + generation uint64 + captureParent context.Context + captureContext context.Context + captureCancel context.CancelFunc + captureTimeout time.Duration + wake chan struct{} + results chan browserModeResult + toggles chan input.Command + stickyModes map[string]review.DocumentMode + cancel context.CancelFunc + done chan struct{} + document atspi.ObjectID + navigation atspi.ObjectID + navigationDocument atspi.ObjectID + navigationFocus atspi.ObjectID + navigationInput input.Activity + navigationAt time.Time + commandAt time.Time + state review.DocumentModeState + mode review.DocumentMode + ready bool + resolving bool +} + +func newBrowserModeController(source any, keys browseKeyOwner, capturer documentContextCapturer) *browserModeController { + return &browserModeController{source: source, keys: keys, capturer: capturer, captureTimeout: 2 * time.Second, wake: make(chan struct{}, 1), results: make(chan browserModeResult, 1), toggles: make(chan input.Command, 8), stickyModes: make(map[string]review.DocumentMode)} +} + +func (browser *browserModeController) invalidateCaptureContextLocked() { + if browser.captureParent == nil { + return + } + if browser.captureCancel != nil { + browser.captureCancel() + browser.captureCancel = nil + } + browser.captureContext, browser.captureCancel = context.WithCancel(browser.captureParent) +} + +func (browser *browserModeController) RecentInput() input.Activity { + return recentInput(browser.source) +} +func (browser *browserModeController) Start(ctx context.Context) error { + ctx, browser.cancel = context.WithCancel(ctx) + browser.mutex.Lock() + browser.captureParent = ctx + browser.captureContext = ctx + browser.captureCancel = nil + browser.mutex.Unlock() + browser.done = make(chan struct{}) + go func() { + defer close(browser.done) + for { + select { + case <-ctx.Done(): + return + case <-browser.wake: + } + if ctx.Err() != nil { + return + } + browser.mutex.Lock() + focus, generation, origin, captureContext := browser.focus, browser.generation, browser.origin, browser.captureContext + browser.mutex.Unlock() + deadline, cancel := context.WithTimeout(captureContext, browser.captureTimeout) + owner, err := browser.capturer.CaptureDocumentContext(deadline, focus) + cancel() + browser.mutex.Lock() + current := focus == browser.focus && generation == browser.generation + browser.mutex.Unlock() + if !current { + continue + } + if errors.Is(err, context.DeadlineExceeded) && captureContext.Err() == nil { + // Retry a slow provider while this focus is still current. A focus + // or load change cancels the wait and leaves its own wake pending. + timer := time.NewTimer(200 * time.Millisecond) + select { + case <-timer.C: + case <-captureContext.Done(): + case <-ctx.Done(): + } + timer.Stop() + if captureContext.Err() == nil && ctx.Err() == nil { + select { + case browser.wake <- struct{}{}: + default: + } + } + continue + } + select { + case browser.results <- browserModeResult{focus: focus, generation: generation, owner: owner, origin: origin, err: err}: + case <-ctx.Done(): + return + } + } + }() + return nil +} +func (browser *browserModeController) Stop(ctx context.Context) error { + browser.keys.SetBrowseNavigation(false) + if browser.cancel == nil { + return nil + } + browser.cancel() + select { + case <-browser.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (browser *browserModeController) follow(focus atspi.ObjectID) { + browser.followWithOrigin(focus, review.NativeNavigation) +} + +func (browser *browserModeController) followCaret(focus atspi.ObjectID) { + browser.followWithOrigin(focus, review.CaretNavigation) +} + +func (browser *browserModeController) clear() { + browser.nativeButtonFocus(false) + browser.mutex.Lock() + browser.focus = atspi.ObjectID{} + browser.document = atspi.ObjectID{} + browser.navigation = atspi.ObjectID{} + browser.navigationDocument = atspi.ObjectID{} + browser.navigationFocus = atspi.ObjectID{} + browser.generation++ + browser.resolving = false + browser.ready = false + browser.mode = review.InactiveMode + browser.state = review.DocumentModeState{} + browser.invalidateCaptureContextLocked() + browser.mutex.Unlock() + browser.keys.SetBrowseNavigation(false) +} + +func (browser *browserModeController) nativeButtonFocus(focused bool) { + if keys, ok := browser.keys.(interface{ SetNativeButtonFocus(bool) }); ok { + keys.SetNativeButtonFocus(focused) + } +} + +func (browser *browserModeController) followWithOrigin(focus atspi.ObjectID, origin review.NavigationOrigin) { + browser.mutex.Lock() + if focus.Valid() && focus == browser.focus && (browser.ready || browser.resolving) && (origin == browser.origin || browser.ready && browser.mode == review.BrowseMode) { + browser.mutex.Unlock() + return + } + browser.focus = focus + browser.navigationFocus = atspi.ObjectID{} + browser.origin = origin + browser.generation++ + browser.resolving = true + browser.invalidateCaptureContextLocked() + browser.mutex.Unlock() + if origin != review.CaretNavigation { + browser.ready = false + browser.keys.SetBrowseNavigation(false) + } + select { + case browser.wake <- struct{}{}: + default: + } +} +func (browser *browserModeController) accept(result browserModeResult, speech speechEnqueuer) { + browser.mutex.Lock() + current := result.focus == browser.focus && result.generation == browser.generation + browser.mutex.Unlock() + if !current { + return + } + browser.resolving = false + if result.err != nil { + browser.ready = false + browser.mode = review.InactiveMode + browser.keys.SetBrowseNavigation(false) + return + } + if result.owner.TopLevel.ID() != browser.document { + browser.document = result.owner.TopLevel.ID() + browser.state = review.DocumentModeState{} + browser.state.SetSticky(browser.stickyModes[browser.document.BusName]) + } + browser.ready = true + modeInput := runtimeModeInput(result.owner) + modeInput.Origin = result.origin + mode := browser.state.Update(modeInput, review.DefaultModeSettings()) + browser.set(mode, speech, false) +} +func (browser *browserModeController) set(mode review.DocumentMode, speech speechEnqueuer, manual bool) { + previous := browser.mode + browser.mode = mode + browser.keys.SetBrowseNavigation(mode == review.BrowseMode) + // Python starts web content in browse mode. Entering ordinary document + // content is therefore silent; only an actual automatic mode change (for + // example, browse to focus for an entry) is announced. + if mode == review.InactiveMode || (!manual && (mode == previous || previous == review.InactiveMode && mode == review.BrowseMode)) { + return + } + text := "Browse mode" + cue := "browse_mode" + if mode == review.FocusMode { + text = "Focus mode" + cue = "focus_mode" + } + if manual { + speech.Interrupt() + } + // Append automatic changes so they do not replace the focused control. + speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual}) +} +func (browser *browserModeController) toggle(speech speechEnqueuer) { + if !browser.ready || browser.mode == review.InactiveMode { + speech.Enqueue(speechRequest{Text: "Not in web document content.", Role: "system", Cause: "document-mode"}) + return + } + delete(browser.stickyModes, browser.document.BusName) + browser.set(browser.state.Toggle(), speech, true) +} + +// Until table layout/PDF/action semantics are resolved, ambiguous widgets stay +// in focus mode. Editable and composite ancestors also keep letters available +// to the application. This is a deliberate conservative runtime subset of the +// more complete reference policy, not guessed false values for missing traits. +func runtimeModeInput(owner atspi.DocumentContext) review.ModeInput { + result := review.ModeInput{Context: owner} + for _, ancestor := range owner.Ancestors { + if ancestor.Editable { + result.Traits.ContentEditableWithEmbeddedObjects = true + } + switch ancestor.Role { + case "table", "table cell", "tree table": + result.Traits.GridDescendant = true + } + } + return result +} + +func (browser *browserModeController) navigated(position review.DocumentPosition, focus atspi.ObjectID) { + browser.mutex.Lock() + browser.focus = position.Snapshot.ID() + browser.origin = position.Origin + browser.generation++ + browser.navigationFocus = focus + browser.navigation = position.Snapshot.ID() + browser.navigationDocument = position.Document.ID() + browser.navigationInput = browser.RecentInput() + browser.navigationAt = time.Now() + browser.invalidateCaptureContextLocked() + browser.mutex.Unlock() + browser.document = position.Document.ID() + browser.state = review.DocumentModeState{} + browser.state.SetSticky(browser.stickyModes[browser.document.BusName]) + browser.mode = browser.state.Current() + browser.ready = true + browser.keys.SetBrowseNavigation(browser.mode == review.BrowseMode) +} + +func (browser *browserModeController) acceptsDocumentResult(result review.Result) bool { + browser.mutex.Lock() + focus := browser.focus + anchor := browser.navigationFocus + browser.mutex.Unlock() + // A pending chrome/other-document focus must not be overwritten by an + // old structural result. Native focus on the target itself is its echo. + if !browser.ready && focus != result.DocumentFocus && focus != result.DocumentPosition.Snapshot.ID() { + return false + } + if browser.mode == review.InactiveMode { + return result.DocumentFragment && focus.Valid() && result.DocumentFocus == focus + } + if (result.DocumentCaretCommand != 0 || result.DocumentActivate) && browser.mode != review.BrowseMode { + return false + } + if result.Err != nil { + // Caret navigation retains the worker's native focus anchor while the + // browser cursor follows a text leaf. Failures have no target position; + // match that anchor so a timeout cannot turn into a silently dead key. + return result.DocumentFocus.Valid() && (result.DocumentFocus == focus || browser.ready && result.DocumentFocus == anchor) + } + if result.DocumentActivate { + return result.DocumentFocus.Valid() && (result.DocumentFocus == focus || browser.ready && result.DocumentFocus == anchor) + } + return result.DocumentPosition.Document.ID() == browser.document +} + +// A load can reuse both the document path and the last focused object path. +// Force context resolution and retire the previous document generation. +func (browser *browserModeController) loaded(focus atspi.ObjectID) { + browser.mutex.Lock() + browser.focus = atspi.ObjectID{} + browser.navigation = atspi.ObjectID{} + browser.navigationDocument = atspi.ObjectID{} + browser.navigationAt = time.Time{} + browser.navigationFocus = atspi.ObjectID{} + browser.invalidateCaptureContextLocked() + browser.mutex.Unlock() + browser.document = atspi.ObjectID{} + browser.mode = review.InactiveMode + browser.state = review.DocumentModeState{} + browser.follow(focus) +} + +// Loading retires the old document while keeping window ownership with the +// event loop. A later busy:false, load-stopped, or load-complete can resolve +// the current document again. +func (browser *browserModeController) loading() { + browser.mutex.Lock() + browser.generation++ + browser.resolving = false + browser.ready = false + browser.focus = atspi.ObjectID{} + browser.document = atspi.ObjectID{} + browser.navigation = atspi.ObjectID{} + browser.navigationDocument = atspi.ObjectID{} + browser.navigationFocus = atspi.ObjectID{} + browser.navigationAt = time.Time{} + browser.mode = review.InactiveMode + browser.state = review.DocumentModeState{} + browser.invalidateCaptureContextLocked() + browser.mutex.Unlock() + browser.keys.SetBrowseNavigation(false) +} + +func (browser *browserModeController) observeCommand(command input.Command) { + target, _, _ := documentReviewCommand(command) + browser.mutex.Lock() + browser.commandAt = time.Now() + if target == 0 { + browser.navigation = atspi.ObjectID{} + browser.navigationDocument = atspi.ObjectID{} + browser.navigationAt = time.Time{} + } + browser.mutex.Unlock() +} + +// recentConsumedCommand reports whether X11 just dispatched one of Cthulhu's +// passive command grabs. Some toolkits emit a transient window deactivation as +// a side effect of that grab. Python Cthulhu ignores the same event when its +// last keyboard event was consumed. +func (browser *browserModeController) recentConsumedCommand() bool { + if state, ok := browser.keys.(commandGrabState); ok && state.CommandGrabActive() { + return true + } + browser.mutex.Lock() + defer browser.mutex.Unlock() + return !browser.commandAt.IsZero() && time.Since(browser.commandAt) <= 100*time.Millisecond +} + +// A new unshifted browse arrow is owned by Go. Retain its cursor when the +// previous placement's native caret arrives after that arrow was queued. +func (browser *browserModeController) retainsBrowseCaret(event atspi.Event) bool { + browser.mutex.Lock() + defer browser.mutex.Unlock() + return browser.ready && browser.mode == review.BrowseMode && browser.navigationDocument.Valid() && + browser.navigationDocument.BusName == event.Source.BusName +} + +func (browser *browserModeController) suppressNavigationEcho(event atspi.Event) bool { + if event.Type == atspi.WindowActivatedEvent || isMenuRole(event.Source.Role) { + return false + } + browser.mutex.Lock() + activity := browser.RecentInput() + // A fast repeat can press the next structural-navigation key before + // Chromium emits the caret/focus events caused by the previous placement. + // Keep a very short grace period for typing-intent keys (the single-letter + // structural bindings), while native caret and focus-navigation keys still + // end suppression immediately. + recentStructural := activity.Intent == input.TypingIntent && + !browser.navigationAt.IsZero() && time.Since(browser.navigationAt) <= 100*time.Millisecond + if activity.Sequence != browser.navigationInput.Sequence && !recentStructural { + browser.navigation = atspi.ObjectID{} + browser.navigationDocument = atspi.ObjectID{} + browser.navigationAt = time.Time{} + } + target, document := browser.navigation, browser.navigationDocument + browser.mutex.Unlock() + if document.Valid() && event.Type == atspi.AccessibleNameEvent && + event.Source.ID().BusName == document.BusName { + return true + } + if event.Type != atspi.CaretEvent && !isFocusEvent(event.Type) { + return false + } + if target.Valid() && event.Source.ID() == target { + return true + } + // Chromium commonly reports the caret or focus on a newly-created child + // of the structural target rather than on the target itself. The child can + // have an identity which was not present in the captured tree, so ancestry + // cannot be resolved reliably here. Navigation suppression is already + // bounded by the next native input sequence; within that bound, treat + // focus/caret events from the target's browser application as echoes. This + // prevents a child static/link announcement from replacing the structural + // presentation and from moving the independent document cursor to an + // uncaptured identity. + return document.Valid() && event.Source.ID().BusName == document.BusName +} + +func (browser *browserModeController) sticky(mode review.DocumentMode, speech speechEnqueuer) { + if !browser.ready || browser.mode == review.InactiveMode { + speech.Enqueue(speechRequest{Text: "Not in web document content.", Role: "system", Cause: "document-mode"}) + return + } + if len(browser.stickyModes) >= 16 { + clear(browser.stickyModes) + } + browser.stickyModes[browser.document.BusName] = mode + browser.state.SetSticky(mode) + browser.mode = mode + browser.keys.SetBrowseNavigation(mode == review.BrowseMode) + text := "Browse mode is sticky." + if mode == review.FocusMode { + text = "Focus mode is sticky." + } + speech.Interrupt() + speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "document-mode"}) +} diff --git a/internal/app/browser_mode_test.go b/internal/app/browser_mode_test.go new file mode 100644 index 0000000..476a895 --- /dev/null +++ b/internal/app/browser_mode_test.go @@ -0,0 +1,638 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +type modeKeys struct { + enabled bool + nativeButtonFocus bool +} + +func (keys *modeKeys) SetBrowseNavigation(enabled bool) { keys.enabled = enabled } +func (keys *modeKeys) SetNativeButtonFocus(focused bool) { + keys.nativeButtonFocus = focused +} + +func TestBrowserModeClearsNativeButtonFocus(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + browser.nativeButtonFocus(true) + if !keys.nativeButtonFocus { + t.Fatal("focused button did not request native Return") + } + browser.clear() + if keys.nativeButtonFocus { + t.Fatal("departed button retained native Return policy") + } +} + +type modeSoundQueue struct { + speech *fakeSpeechQueue + sounds chan sound.Cue +} + +func (queue *modeSoundQueue) Enqueue(request speechRequest) { + queue.speech.Enqueue(request) + for _, cue := range request.Sound { + queue.sounds <- cue + } +} + +func (queue *modeSoundQueue) Interrupt() { + queue.speech.Interrupt() +} + +func modeContext(t *testing.T, role string, editableAncestor bool) atspi.DocumentContext { + t.Helper() + return modeContextInDocument(t, role, editableAncestor, "/document") +} +func modeContextInDocument(t *testing.T, role string, editableAncestor bool, documentPath string) atspi.DocumentContext { + t.Helper() + snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/target", Role: role, StatesKnown: true} + doc := atspi.Snapshot{BusName: ":1.42", Path: documentPath, Role: "document web", StatesKnown: true, Editable: editableAncestor} + tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{snapshot.ID()}}, {Snapshot: snapshot}}) + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Reference(snapshot.ID()) + owner, err := tree.DocumentContext(ref) + if err != nil { + t.Fatal(err) + } + return owner +} +func TestBrowserModeRejectsDepartedFocusAndPreservesTyping(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + queue := &fakeSpeechQueue{spoken: make(chan string, 16)} + heading := modeContext(t, "heading", false) + focus := heading.Ancestors[0].ID() + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue) + if !keys.enabled { + t.Fatal("heading did not enable browse keys") + } + browser.toggle(queue) + if keys.enabled { + t.Fatal("manual focus mode retained letters") + } + browser.toggle(queue) + if !keys.enabled { + t.Fatal("manual browse mode did not restore letters") + } + chrome := atspi.NewObjectID(":1.42", "/chrome") + browser.follow(chrome) + if keys.enabled { + t.Fatal("leaving content did not immediately release letters") + } + browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue) + if keys.enabled { + t.Fatal("late result re-enabled letters in chrome") + } + browser.accept(browserModeResult{focus: chrome, generation: 2, err: atspi.ErrNotInDocument}, queue) + browser.toggle(queue) + if keys.enabled { + t.Fatal("toggle in chrome enabled letters") + } + entry := modeContext(t, "entry", false) + entryFocus := atspi.NewObjectID(":1.42", "/entry") + browser.follow(entryFocus) + browser.accept(browserModeResult{focus: entryFocus, generation: 3, owner: entry}, queue) + if keys.enabled || browser.mode != review.FocusMode { + t.Fatal("entry did not preserve typing") + } + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 4, owner: heading}, queue) + if !keys.enabled { + t.Fatal("return to content did not restore navigation") + } +} + +func TestBrowserModeDuplicateFocusKeepsBrowseRouting(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + heading := modeContext(t, "heading", false) + focus := heading.Ancestors[0].ID() + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, &fakeSpeechQueue{spoken: make(chan string, 1)}) + if !browser.ready || !keys.enabled { + t.Fatal("initial browse routing was not ready") + } + browser.follow(focus) + if !browser.ready || !keys.enabled || browser.generation != 1 { + t.Fatal("duplicate focus restarted browser capture or released browse routing") + } +} + +func TestBrowserCaretLeavesFocusModeForDocumentText(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + entry := modeContext(t, "entry", false) + entryFocus := atspi.NewObjectID(":1.42", "/entry") + browser.follow(entryFocus) + browser.accept(browserModeResult{focus: entryFocus, generation: 1, owner: entry}, queue) + if browser.mode != review.FocusMode || keys.enabled { + t.Fatal("entry did not enter focus mode") + } + + paragraph := modeContext(t, "paragraph", false) + caret := paragraph.Ancestors[0].ID() + browser.followCaret(caret) + if browser.focus != caret || browser.origin != review.CaretNavigation { + t.Fatal("document caret was not tracked") + } + browser.accept(browserModeResult{focus: caret, generation: 2, owner: paragraph, origin: review.CaretNavigation}, queue) + if browser.mode != review.BrowseMode || !keys.enabled { + t.Fatal("caret navigation did not restore browse keys") + } + browser.followCaret(caret) + if browser.generation != 2 || !keys.enabled { + t.Fatal("repeated caret movement restarted browse routing") + } +} + +func TestBrowserModePlaysModeChangeSounds(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + speech := &fakeSpeechQueue{spoken: make(chan string, 16)} + queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 16)} + heading := modeContext(t, "heading", false) + entry := modeContext(t, "entry", false) + focus := heading.Ancestors[0].ID() + + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue) + if len(queue.sounds) != 0 || len(speech.spoken) != 0 { + t.Fatal("initial browse mode was announced") + } + + browser.toggle(queue) + assertModeSound(t, queue, speech, "focus_mode", "Focus mode") + + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 2, owner: entry}, queue) + if len(queue.sounds) != 0 { + t.Fatalf("unchanged mode queued cue %#v", <-queue.sounds) + } + + browser.toggle(queue) + assertModeSound(t, queue, speech, "browse_mode", "Browse mode") +} + +func assertModeSound(t *testing.T, queue *modeSoundQueue, speech *fakeSpeechQueue, wantCue, wantText string) { + t.Helper() + select { + case cue := <-queue.sounds: + if cue.Name != wantCue || cue.Pan != 0 { + t.Fatalf("got mode cue %#v want centered %q", cue, wantCue) + } + case <-time.After(time.Second): + t.Fatalf("mode cue %q was not queued", wantCue) + } + select { + case text := <-speech.spoken: + if text != wantText { + t.Fatalf("got mode text %q want %q", text, wantText) + } + case <-time.After(time.Second): + t.Fatalf("mode text %q was not spoken", wantText) + } +} + +func TestBrowserModeAcceptsSameDocumentNavigationDuringRecapture(t *testing.T) { + browser := newBrowserModeController(nil, &modeKeys{}, nil) + heading := modeContext(t, "heading", false) + focus := heading.Ancestors[0].ID() + browser.follow(focus) + queue := &fakeSpeechQueue{spoken: make(chan string, 1)} + browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue) + position := review.DocumentPosition{ + Document: heading.TopLevel, + Snapshot: heading.Ancestors[0], + } + if !browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) { + t.Fatal("same-document navigation result rejected") + } + browser.follow(atspi.NewObjectID(":1.42", "/second")) + if browser.ready { + t.Fatal("new focus did not start recapture") + } + if browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) { + t.Fatal("old result accepted while the new focus document is unknown") + } + browser.follow(position.Snapshot.ID()) + if !browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) { + t.Fatal("native focus echo on the structural target rejected") + } + other := modeContextInDocument(t, "heading", false, "/other_document").TopLevel + if browser.acceptsDocumentResult(review.Result{DocumentPosition: review.DocumentPosition{Document: other}}) { + t.Fatal("other-document result accepted") + } + if browser.acceptsDocumentResult(review.Result{Err: errors.New("capture failed"), DocumentPosition: position}) { + t.Fatal("failed result accepted") + } + if !browser.acceptsDocumentResult(review.Result{Err: errors.New("capture failed"), DocumentFocus: position.Snapshot.ID()}) { + t.Fatal("current navigation failure would be silently discarded") + } +} +func TestBrowserRuntimeModeProtectsAmbiguousAndEditableContent(t *testing.T) { + for _, tc := range []struct { + role string + editable bool + want review.DocumentMode + }{ + {"heading", false, review.BrowseMode}, {"entry", false, review.FocusMode}, + {"paragraph", true, review.FocusMode}, {"table cell", false, review.FocusMode}, + {"embedded", false, review.FocusMode}, {"menu item", false, review.FocusMode}, + } { + t.Run(tc.role, func(t *testing.T) { + var state review.DocumentModeState + got := state.Update(runtimeModeInput(modeContext(t, tc.role, tc.editable)), review.DefaultModeSettings()) + if got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +type canceledModeCapture struct{ started chan struct{} } + +func (capture canceledModeCapture) CaptureDocumentContext(ctx context.Context, _ atspi.ObjectID) (atspi.DocumentContext, error) { + close(capture.started) + <-ctx.Done() + return atspi.DocumentContext{}, ctx.Err() +} +func TestBrowserModeShutdownCancelsNativeWait(t *testing.T) { + capture := canceledModeCapture{make(chan struct{})} + browser := newBrowserModeController(nil, &modeKeys{}, capture) + if err := browser.Start(context.Background()); err != nil { + t.Fatal(err) + } + browser.follow(atspi.NewObjectID(":1.42", "/target")) + select { + case <-capture.started: + case <-time.After(time.Second): + t.Fatal("capture did not start") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := browser.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +type timeoutThenModeCapture struct { + calls chan struct{} + owner atspi.DocumentContext +} + +func (capture *timeoutThenModeCapture) CaptureDocumentContext(ctx context.Context, _ atspi.ObjectID) (atspi.DocumentContext, error) { + capture.calls <- struct{}{} + if len(capture.calls) == 1 { + <-ctx.Done() + return atspi.DocumentContext{}, ctx.Err() + } + return capture.owner, nil +} + +func TestBrowserModeRetriesTimedOutCurrentFocus(t *testing.T) { + owner := modeContext(t, "heading", false) + focus := owner.Ancestors[0].ID() + capture := &timeoutThenModeCapture{calls: make(chan struct{}, 2), owner: owner} + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, capture) + browser.captureTimeout = 20 * time.Millisecond + if err := browser.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer browser.Stop(context.Background()) + browser.follow(focus) + select { + case result := <-browser.results: + browser.accept(result, &fakeSpeechQueue{spoken: make(chan string, 1)}) + if result.err != nil || result.focus != focus || !keys.enabled || len(capture.calls) != 2 { + t.Fatalf("timed-out capture did not recover: %+v, calls %d", result, len(capture.calls)) + } + case <-time.After(time.Second): + t.Fatal("timed-out capture was not retried") + } +} + +type cancelableModeCapture struct { + started chan atspi.ObjectID + canceled chan atspi.ObjectID +} + +func (capture *cancelableModeCapture) CaptureDocumentContext(ctx context.Context, focus atspi.ObjectID) (atspi.DocumentContext, error) { + started := false + for !started { + select { + case capture.started <- focus: + started = true + case <-ctx.Done(): + return atspi.DocumentContext{}, ctx.Err() + } + } + <-ctx.Done() + select { + case capture.canceled <- focus: + case <-context.Background().Done(): + } + return atspi.DocumentContext{}, ctx.Err() +} + +func TestBrowserModeCancelsCaptureOnLoadLifecycle(t *testing.T) { + first := atspi.NewObjectID(":1.42", "/first") + second := atspi.NewObjectID(":1.42", "/second") + capture := &cancelableModeCapture{ + started: make(chan atspi.ObjectID, 1), + canceled: make(chan atspi.ObjectID, 1), + } + browser := newBrowserModeController(nil, &modeKeys{}, capture) + if err := browser.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer browser.Stop(context.Background()) + + browser.follow(first) + select { + case got := <-capture.started: + if got != first { + t.Fatalf("first capture focus = %v", got) + } + case <-time.After(time.Second): + t.Fatal("first capture did not start") + } + + browser.loading() + select { + case got := <-capture.canceled: + if got != first { + t.Fatalf("canceled capture focus = %v", got) + } + case <-time.After(time.Second): + t.Fatal("page loading did not cancel the old capture") + } + if len(capture.started) != 0 { + t.Fatal("page loading started a new capture before load completion") + } + browser.loaded(second) + select { + case got := <-capture.started: + if got != second { + t.Fatalf("second capture focus = %v", got) + } + case <-time.After(time.Second): + t.Fatal("load completion did not start the latest capture") + } +} + +func TestBrowserLoadingRejectsOldCaptureAndRecoversOnStop(t *testing.T) { + owner := modeContext(t, "heading", false) + focus := owner.Ancestors[0].ID() + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 1, owner: owner}, speech) + if !keys.enabled { + t.Fatal("initial page did not enable browse mode") + } + browser.loading() + if browser.ready || keys.enabled { + t.Fatal("reloading page retained old browse keys") + } + browser.accept(browserModeResult{focus: focus, generation: 1, owner: owner}, speech) + if browser.ready || keys.enabled { + t.Fatal("late pre-reload capture restored the old page") + } + browser.loaded(focus) + browser.accept(browserModeResult{focus: focus, generation: browser.generation, owner: owner}, speech) + if !browser.ready || !keys.enabled { + t.Fatal("stopped page load did not restore browse mode") + } +} + +func TestBrowserModeSuppressesStructuralNavigationEcho(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + target := atspi.NewSnapshot("/target", "Target", "heading", "", "") + target.BusName = ":1.42" + owner := modeContext(t, "heading", false) + browser.navigated(review.DocumentPosition{ + Document: owner.TopLevel, + Snapshot: target, + }, target.ID()) + + for _, eventType := range []string{atspi.CaretEvent, atspi.FocusEvent, atspi.FocusedStateEvent} { + if !browser.suppressNavigationEcho(atspi.NewEvent(eventType, target)) { + t.Fatalf("%s echo was not suppressed", eventType) + } + } + child := atspi.NewSnapshot("/new-child", "Child", "static", "", "") + child.BusName = ":1.42" + for _, eventType := range []string{atspi.CaretEvent, atspi.FocusEvent, atspi.FocusedStateEvent} { + if !browser.suppressNavigationEcho(atspi.NewEvent(eventType, child)) { + t.Fatalf("%s descendant echo was not suppressed", eventType) + } + } + + other := atspi.NewSnapshot("/other", "Other", "heading", "", "") + other.BusName = ":1.99" + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, other)) { + t.Fatal("unrelated focus echo was suppressed") + } + + browser.observeCommand(input.ToggleDocumentMode) + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, target)) { + t.Fatal("non-structural command retained navigation suppression") + } +} + +func TestBrowserNavigationEchoEndsOnNativeKey(t *testing.T) { + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "h")) + browser := newBrowserModeController(tracker, &modeKeys{}, nil) + target := atspi.Snapshot{BusName: ":1.42", Path: "/heading", Role: "heading"} + browser.navigated(review.DocumentPosition{Snapshot: target}, target.ID()) + if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) { + t.Fatal("structural echo was not suppressed") + } + tracker.Observe(input.NewKeyEvent(0, true, "Up")) + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) { + t.Fatal("native movement back into heading was suppressed") + } +} + +func TestBrowserTracksRecentlyConsumedCommand(t *testing.T) { + browser := newBrowserModeController(nil, &modeKeys{}, nil) + if browser.recentConsumedCommand() { + t.Fatal("new browser controller reported a consumed command") + } + browser.observeCommand(input.ReviewNextLine) + if !browser.recentConsumedCommand() { + t.Fatal("fresh command was not reported as consumed") + } + browser.mutex.Lock() + browser.commandAt = time.Now().Add(-time.Second) + browser.mutex.Unlock() + if browser.recentConsumedCommand() { + t.Fatal("stale command was reported as recently consumed") + } +} + +type activeCommandKeys struct { + modeKeys + active bool +} + +func (keys *activeCommandKeys) CommandGrabActive() bool { return keys.active } + +func TestBrowserRecognizesCommandGrabBeforeDispatch(t *testing.T) { + keys := &activeCommandKeys{active: true} + browser := newBrowserModeController(nil, keys, nil) + if !browser.recentConsumedCommand() { + t.Fatal("active command grab was not recognized before dispatch") + } + keys.active = false + if browser.recentConsumedCommand() { + t.Fatal("inactive command grab was reported without a dispatched command") + } +} + +func TestBrowserNavigationEchoSurvivesFastStructuralRepeat(t *testing.T) { + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "h")) + browser := newBrowserModeController(tracker, &modeKeys{}, nil) + owner := modeContext(t, "heading", false) + target := atspi.Snapshot{BusName: ":1.42", Path: "/heading", Role: "heading"} + browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, target.ID()) + + tracker.Observe(input.NewKeyEvent(0, true, "h")) + child := atspi.Snapshot{BusName: ":1.42", Path: "/replacement", Role: "static"} + if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, child)) { + t.Fatal("fast repeated structural command exposed prior caret echo") + } +} + +func TestBrowserLoadRetiresPositionEvenWhenPathIsReused(t *testing.T) { + browser := newBrowserModeController(nil, &modeKeys{}, nil) + owner := modeContext(t, "heading", false) + position := review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0]} + browser.navigated(position, position.Snapshot.ID()) + browser.loaded(position.Snapshot.ID()) + if browser.ready || browser.mode != review.InactiveMode || browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) { + t.Fatal("load retained the old document position") + } + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, position.Snapshot)) { + t.Fatal("load retained old navigation echo suppression") + } + select { + case <-browser.wake: + default: + t.Fatal("same-path load did not schedule fresh context capture") + } +} + +func TestBrowserLoadAcceptsCurrentFragmentTarget(t *testing.T) { + browser := newBrowserModeController(nil, &modeKeys{}, nil) + document := atspi.NewObjectID(":1.42", "/document") + browser.loaded(document) + result := review.Result{DocumentFragment: true, DocumentFocus: document, Moved: true} + if !browser.acceptsDocumentResult(result) { + t.Fatal("current document fragment was rejected before context resolution") + } + result.DocumentFocus = atspi.NewObjectID(":1.42", "/old_document") + if browser.acceptsDocumentResult(result) { + t.Fatal("old document fragment crossed a page load") + } +} + +func TestBrowserModeRetriesSameFocusAfterCaptureFailure(t *testing.T) { + browser := newBrowserModeController(nil, &modeKeys{}, nil) + owner := modeContext(t, "heading", false) + focus := owner.Ancestors[0].ID() + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: 1, err: atspi.ErrStaleObject}, &fakeSpeechQueue{}) + browser.follow(focus) + if browser.generation != 2 { + t.Fatal("transient context failure permanently disabled same-object navigation") + } + browser.accept(browserModeResult{focus: focus, generation: 2, owner: owner}, &fakeSpeechQueue{}) + if !browser.ready || browser.mode != review.BrowseMode { + t.Fatal("same-object retry did not restore browse mode") + } +} + +func TestBrowserReportsNavigationFailureAfterCaretMovesFromNativeFocus(t *testing.T) { + browser := newBrowserModeController(nil, &modeKeys{}, nil) + owner := modeContext(t, "heading", false) + anchor := owner.TopLevel.ID() + position := review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0], Origin: review.CaretNavigation} + browser.navigated(position, anchor) + for _, result := range []review.Result{ + {DocumentCaretCommand: review.CaretNextLine, DocumentFocus: anchor, Err: context.DeadlineExceeded}, + {DocumentTarget: review.DocumentHeading, DocumentFocus: anchor, Err: context.DeadlineExceeded}, + {DocumentActivate: true, DocumentFocus: anchor, Err: context.DeadlineExceeded}, + } { + if !browser.acceptsDocumentResult(result) { + t.Fatalf("current navigation error was silently discarded: %+v", result) + } + result.DocumentFocus = atspi.NewObjectID(anchor.BusName, "/another-document") + if browser.acceptsDocumentResult(result) { + t.Fatal("accepted an unrelated navigation error") + } + } + browser.follow(atspi.NewObjectID(anchor.BusName, "/address-bar")) + if browser.acceptsDocumentResult(review.Result{DocumentCaretCommand: review.CaretNextLine, DocumentFocus: anchor, Err: context.DeadlineExceeded}) { + t.Fatal("old document error interrupted chrome focus") + } +} + +func TestStickyBrowserModeSurvivesNavigationChromeAndWindowSuspension(t *testing.T) { + for _, mode := range []review.DocumentMode{review.BrowseMode, review.FocusMode} { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + queue := &fakeSpeechQueue{spoken: make(chan string, 20)} + owner := modeContext(t, "paragraph", false) + focus := owner.Ancestors[0].ID() + accept := func(owner atspi.DocumentContext) { + browser.follow(focus) + browser.accept(browserModeResult{focus: focus, generation: browser.generation, owner: owner}, queue) + } + accept(owner) + browser.sticky(mode, queue) + browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0]}, focus) + if browser.mode != mode { + t.Fatal("navigation discarded sticky mode") + } + browser.clear() + accept(modeContext(t, "entry", false)) + if browser.mode != mode || keys.enabled != (mode == review.BrowseMode) { + t.Fatal("window return lost sticky mode") + } + browser.loaded(focus) + accept(modeContextInDocument(t, "entry", false, "/new_document")) + if browser.mode != mode { + t.Fatal("new document lost sticky mode") + } + browser.toggle(queue) + if browser.state.Sticky() != review.InactiveMode || len(browser.stickyModes) != 0 { + t.Fatal("single press failed to clear sticky mode") + } + } +} diff --git a/internal/app/browser_tabs_native_test.go b/internal/app/browser_tabs_native_test.go new file mode 100644 index 0000000..9ac5ef5 --- /dev/null +++ b/internal/app/browser_tabs_native_test.go @@ -0,0 +1,125 @@ +// 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/platform/xorg" +) + +func TestNativeBrowserTabs(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" && os.Getenv("CTHULHU_NATIVE_FIREFOX") != "1" { + t.Skip("requires a private browser 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()) + listener := xorg.New() + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 128)} + logFile, err := os.CreateTemp("/tmp", "cthulhu-tabs-events-*.log") + if err != nil { + t.Fatal(err) + } + defer logFile.Close() + t.Logf("event diagnostics: %s", logFile.Name()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.NewTextHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})), queue, adapter, nil, nil, 0, nil, listener, nil, nil) + }() + defer func() { cancel(); <-done }() + keys := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + wait := func(role, text string) speechRequest { + t.Helper() + for { + select { + case request := <-queue.requests: + t.Logf("speech: %s %q (%s, append=%v)", request.Role, request.Text, request.Cause, request.Append) + if request.Role == role && (text == "" || request.Text == text || + role == "page tab" && strings.Contains(request.Text, text)) { + return request + } + case <-ctx.Done(): + t.Fatalf("missing %s %q: %v", role, text, ctx.Err()) + return speechRequest{} + } + } + } + settle := func(checkTab bool) { + t.Helper() + // Drain the asynchronous event burst before the next chord so a + // duplicate from one operation cannot satisfy the next assertion. + timer := time.NewTimer(150 * time.Millisecond) + defer timer.Stop() + for { + select { + case request := <-queue.requests: + if checkTab && request.Role == "page tab" { + t.Fatalf("extra tab announcement without another chord: %#v", request) + } + if checkTab && (request.Cause == atspi.TextDeletedEvent || request.Cause == atspi.TextInsertedEvent) { + t.Fatalf("automatic address rewrite spoken: %#v", request) + } + timer.Reset(150 * time.Millisecond) + case <-timer.C: + return + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + keys("ctrl+l") + // Firefox's address control is exposed as a combo box. + role := "entry" + if os.Getenv("CTHULHU_NATIVE_FIREFOX") == "1" { + role = "combo box" + } + wait(role, "") + settle(false) + for i := 0; i < 2; i++ { + keys("ctrl+t") + request := wait("page tab", "New Tab") + if request.Cause != atspi.SelectedStateEvent && request.Cause != atspi.SelectionChangedEvent { + t.Fatalf("new tab did not use selected state: %#v", request) + } + settle(true) + } + keys("ctrl+shift+Tab") + wait("page tab", "New Tab") + settle(true) + keys("ctrl+w") + wait("page tab", "New Tab") + settle(true) + keys("ctrl+w") + wait("page tab", "Cthulhu") + settle(true) + keys("ctrl+l") + keys("x") + wait(role, "x") + settle(false) + keys("BackSpace") + wait(role, "x") +} diff --git a/internal/app/character_attributes.go b/internal/app/character_attributes.go new file mode 100644 index 0000000..b0331fd --- /dev/null +++ b/internal/app/character_attributes.go @@ -0,0 +1,262 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +type characterAttributeSource interface { + ReadCharacterAttributes(context.Context, atspi.ObjectID) (atspi.CharacterAttributes, error) +} + +type characterAttributeResult struct { + object atspi.ObjectID + sequence uint64 + values map[string]string + err error +} + +type characterAttributeRequest struct { + object atspi.ObjectID + sequence uint64 +} + +type characterAttributeReader struct { + requests chan characterAttributeRequest + results chan characterAttributeResult + cancel context.CancelFunc + done chan struct{} +} + +// This reader owns one native query at a time and one waiting request. Native +// work stays on AT-SPI's GLib owner; the event loop remains responsive. +func startCharacterAttributeReader(parent context.Context, adapter Adapter) *characterAttributeReader { + ctx, cancel := context.WithCancel(parent) + reader := &characterAttributeReader{ + requests: make(chan characterAttributeRequest, 1), + results: make(chan characterAttributeResult, 1), + cancel: cancel, + done: make(chan struct{}), + } + source, supported := adapter.(characterAttributeSource) + go func() { + defer close(reader.done) + for { + select { + case <-ctx.Done(): + return + case request := <-reader.requests: + result := characterAttributeResult{object: request.object, sequence: request.sequence} + if supported && request.object.Valid() { + deadline, cancel := context.WithTimeout(ctx, 1200*time.Millisecond) + attributes, err := source.ReadCharacterAttributes(deadline, request.object) + cancel() + result.values, result.err = attributes.Values, err + } else { + result.err = atspi.ErrStaleObject + } + select { + case reader.results <- result: + case <-ctx.Done(): + return + } + } + } + }() + return reader +} + +func (reader *characterAttributeReader) request(object atspi.ObjectID, sequence uint64) { + if !object.Valid() { + return + } + request := characterAttributeRequest{object: object, sequence: sequence} + select { + case reader.requests <- request: + default: + select { + case <-reader.requests: + default: + } + select { + case reader.requests <- request: + default: + } + } +} + +func (reader *characterAttributeReader) stop() { + reader.cancel() + <-reader.done +} + +func presentCharacterAttributes(speech speechEnqueuer, result characterAttributeResult, useColorNames bool, configured ...config.TextAttributeSettings) { + if errors.Is(result.err, atspi.ErrStaleObject) || errors.Is(result.err, context.Canceled) { + return + } + message := "Text attributes are unavailable." + if result.err == nil { + if parts := spokenCharacterAttributes(result.values, useColorNames, configured...); len(parts) != 0 { + message = strings.Join(parts, ", ") + } else { + message = "No text attributes to speak." + } + } + speech.Interrupt() + speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "character-attributes"}) +} + +var characterAttributeOrder = [...]struct { + key, label, ignore string +}{ + {"size", "font size", ""}, + {"family-name", "font family", ""}, + {"weight", "weight", "400"}, + {"indent", "indent", "0"}, + {"underline", "underline", "none"}, + {"strikethrough", "strikethrough", "false"}, + {"justification", "justification", "left"}, + {"style", "style", "normal"}, + {"paragraph-style", "paragraph style", ""}, + {"text-spelling", "spelling", "none"}, + {"fg-color", "foreground color", ""}, + {"bg-color", "background color", ""}, +} + +func spokenCharacterAttributes(values map[string]string, useColorNames bool, configured ...config.TextAttributeSettings) []string { + parts := make([]string, 0, len(characterAttributeOrder)) + order := make([]struct{ key, label, ignore string }, 0, len(characterAttributeOrder)) + if len(configured) == 0 { + order = append(order, characterAttributeOrder[:]...) + } else { + for _, choice := range configured[0].Choices { + if !choice.Speak { + continue + } + for _, known := range characterAttributeOrder { + if choice.Key == known.key { + order = append(order, known) + break + } + } + } + } + for _, attribute := range order { + if attribute.key == "text-spelling" { + if atspi.TextAttributesMisspelled(values) { + parts = append(parts, "misspelled") + } + continue + } + value := strings.TrimSpace(values[attribute.key]) + if value == "" || value == attribute.ignore || value == "0" && attribute.ignore == "0" { + continue + } + switch attribute.key { + case "weight": + if number, ok := parseAttributeInteger(value); value == "bold" || ok && number > 400 { + parts = append(parts, "bold") + continue + } + case "family-name": + value = strings.Trim(strings.TrimSpace(strings.Split(value, ",")[0]), "\"") + case "fg-color", "bg-color": + if color, ok := describeAttributeColor(value, useColorNames); ok { + value = color + } + } + parts = append(parts, attribute.label+": "+value) + } + return parts +} + +func parseAttributeInteger(value string) (int, bool) { + number, err := strconv.Atoi(value) + return number, err == nil +} + +func describeAttributeColor(value string, useColorNames bool) (string, bool) { + red, green, blue, ok := parseAttributeColor(value) + if !ok { + return "", false + } + if useColorNames { + return nearestCSSColor(red, green, blue), true + } + return fmt.Sprintf("%d %d %d", red, green, blue), true +} + +func parseAttributeColor(value string) (int, int, int, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + if strings.HasPrefix(value, "#") { + if len(value) == 4 { + value = "#" + strings.Repeat(value[1:2], 2) + strings.Repeat(value[2:3], 2) + strings.Repeat(value[3:4], 2) + } + if len(value) != 7 { + return 0, 0, 0, false + } + var channels [3]int + for index := range channels { + number, err := strconv.ParseUint(value[1+index*2:3+index*2], 16, 8) + if err != nil { + return 0, 0, 0, false + } + channels[index] = int(number) + } + return channels[0], channels[1], channels[2], true + } + value = strings.TrimPrefix(value, "rgb(") + value = strings.TrimSuffix(value, ")") + fields := strings.Split(value, ",") + if len(fields) != 3 { + return 0, 0, 0, false + } + var channels [3]int + for index, field := range fields { + number, err := strconv.Atoi(strings.TrimSpace(field)) + if err != nil || number < 0 || number > 65535 { + return 0, 0, 0, false + } + channels[index] = number + } + if channels[0] > 255 || channels[1] > 255 || channels[2] > 255 { + for index, number := range channels { + channels[index] = (number*255 + 32767) / 65535 + } + } + return channels[0], channels[1], channels[2], true +} + +func nearestCSSColor(red, green, blue int) string { + hex := fmt.Sprintf("#%02x%02x%02x", red, green, blue) + if name := cssColorNames[hex]; name != "" { + return name + } + bestDistance, secondDistance := int(^uint(0)>>1), int(^uint(0)>>1) + bestHex, secondHex := "", "" + for candidate := range cssColorNames { + r, g, b, _ := parseAttributeColor(candidate) + distance := (r-red)*(r-red) + (g-green)*(g-green) + (b-blue)*(b-blue) + if distance < bestDistance || distance == bestDistance && candidate < bestHex { + secondDistance, secondHex = bestDistance, bestHex + bestDistance, bestHex = distance, candidate + } else if distance < secondDistance || distance == secondDistance && candidate < secondHex { + secondDistance, secondHex = distance, candidate + } + } + if (bestHex == "#000000" || bestHex == "#ffffff") && secondHex != "" && secondDistance-bestDistance < bestDistance { + bestHex = secondHex + } + return cssColorNames[bestHex] +} diff --git a/internal/app/character_attributes_test.go b/internal/app/character_attributes_test.go new file mode 100644 index 0000000..7ff9e4b --- /dev/null +++ b/internal/app/character_attributes_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestCharacterAttributeColorSettingChangesSpokenValues(t *testing.T) { + attributes := map[string]string{ + "family-name": "Liberation Sans, Sans", + "weight": "700", + "underline": "none", + "fg-color": "rgb(65535, 0, 0)", + "bg-color": "#0000ff", + "text-spelling": "spelling", + } + named := strings.Join(spokenCharacterAttributes(attributes, true), ", ") + for _, want := range []string{"font family: Liberation Sans", "bold", "misspelled", "foreground color: red", "background color: blue"} { + if !strings.Contains(named, want) { + t.Errorf("named attributes %q missing %q", named, want) + } + } + if strings.Contains(named, "underline") { + t.Errorf("default underline was spoken: %q", named) + } + numeric := strings.Join(spokenCharacterAttributes(attributes, false), ", ") + for _, want := range []string{"foreground color: 255 0 0", "background color: 0 0 255"} { + if !strings.Contains(numeric, want) { + t.Errorf("numeric attributes %q missing %q", numeric, want) + } + } +} + +func TestConfiguredCharacterAttributesRespectSelectionAndOrder(t *testing.T) { + settings := config.DefaultTextAttributes() + for index := range settings.Choices { + settings.Choices[index].Speak = false + } + settings.Choices[0], settings.Choices[2] = settings.Choices[2], settings.Choices[0] + settings.Choices[0].Speak, settings.Choices[2].Speak = true, true + got := strings.Join(spokenCharacterAttributes(map[string]string{"size": "14", "weight": "700", "underline": "single"}, true, settings), ", ") + if got != "bold, font size: 14" { + t.Fatalf("attributes = %q", got) + } +} + +func TestSpellingAttributeUsesProviderInvalidValue(t *testing.T) { + settings := config.DefaultTextAttributes() + for index := range settings.Choices { + settings.Choices[index].Speak = settings.Choices[index].Key == "text-spelling" + } + got := strings.Join(spokenCharacterAttributes(map[string]string{"invalid": "spelling"}, true, settings), ", ") + if got != "misspelled" { + t.Fatalf("spelling attribute = %q", got) + } +} + +func TestCharacterAttributeColorParsingAndFallback(t *testing.T) { + for _, probe := range []struct { + input, want string + }{ + {"#f00", "red"}, + {"rgb(255, 0, 0)", "red"}, + {"rgb(65535, 0, 0)", "red"}, + {"#fefefe", "white"}, + } { + got, ok := describeAttributeColor(probe.input, true) + if !ok || got != probe.want { + t.Errorf("%q: %q, %v; want %q", probe.input, got, ok, probe.want) + } + } + if got := strings.Join(spokenCharacterAttributes(map[string]string{"fg-color": "transparent"}, true), ", "); got != "foreground color: transparent" { + t.Errorf("unsupported provider color changed: %q", got) + } +} diff --git a/internal/app/character_color_names.go b/internal/app/character_color_names.go new file mode 100644 index 0000000..6f268a5 --- /dev/null +++ b/internal/app/character_color_names.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// CSS color coordinates and names from the Python Cthulhu reference. +package app + +var cssColorNames = map[string]string{ + "#f0f8ff": "alice blue", + "#faebd7": "antique white", + "#7fffd4": "aquamarine", + "#f0ffff": "azure", + "#f5f5dc": "beige", + "#ffe4c4": "bisque", + "#000000": "black", + "#ffebcd": "blanched almond", + "#0000ff": "blue", + "#8a2be2": "blue violet", + "#a52a2a": "brown", + "#deb887": "burlywood", + "#5f9ea0": "cadet blue", + "#7fff00": "chartreuse", + "#d2691e": "chocolate", + "#ff7f50": "coral", + "#6495ed": "cornflower blue", + "#fff8dc": "cornsilk", + "#dc143c": "crimson", + "#00ffff": "cyan", + "#00008b": "dark blue", + "#008b8b": "dark cyan", + "#b8860b": "dark goldenrod", + "#a9a9a9": "dark gray", + "#006400": "dark green", + "#bdb76b": "dark khaki", + "#8b008b": "dark magenta", + "#556b2f": "dark olive green", + "#ff8c00": "dark orange", + "#9932cc": "dark orchid", + "#8b0000": "dark red", + "#e9967a": "dark salmon", + "#8fbc8f": "dark sea green", + "#483d8b": "dark slate blue", + "#2f4f4f": "dark slate gray", + "#00ced1": "dark turquoise", + "#9400d3": "dark violet", + "#ff1493": "deep pink", + "#00bfff": "deep sky blue", + "#696969": "dim gray", + "#1e90ff": "dodger blue", + "#b22222": "fire brick", + "#fffaf0": "floral white", + "#228b22": "forest green", + "#ff00ff": "magenta", + "#dcdcdc": "gainsboro", + "#f8f8ff": "ghost white", + "#ffd700": "gold", + "#daa520": "goldenrod", + "#808080": "gray", + "#008000": "green", + "#adff2f": "green yellow", + "#f0fff0": "honeydew", + "#ff69b4": "hot pink", + "#cd5c5c": "indian red", + "#4b0082": "indigo", + "#fffff0": "ivory", + "#f0e68c": "khaki", + "#e6e6fa": "lavender", + "#fff0f5": "lavender blush", + "#7cfc00": "lawn green", + "#fffacd": "lemon chiffon", + "#add8e6": "light blue", + "#f08080": "light coral", + "#e0ffff": "light cyan", + "#fafad2": "light goldenrod yellow", + "#d3d3d3": "light gray", + "#90ee90": "light green", + "#ffb6c1": "light pink", + "#ffa07a": "light salmon", + "#20b2aa": "light sea green", + "#87cefa": "light sky blue", + "#778899": "light slate gray", + "#b0c4de": "light steel blue", + "#ffffe0": "light yellow", + "#00ff00": "lime", + "#32cd32": "lime green", + "#faf0e6": "linen", + "#800000": "maroon", + "#66cdaa": "medium aquamarine", + "#0000cd": "medium blue", + "#ba55d3": "medium orchid", + "#9370d8": "medium purple", + "#3cb371": "medium sea green", + "#7b68ee": "medium slate blue", + "#00fa9a": "medium spring green", + "#48d1cc": "medium turquoise", + "#c71585": "medium violet red", + "#191970": "midnight blue", + "#f5fffa": "mint cream", + "#ffe4e1": "misty rose", + "#ffe4b5": "moccasin", + "#ffdead": "navajo white", + "#000080": "navy", + "#fdf5e6": "old lace", + "#808000": "olive", + "#6b8e23": "olive drab", + "#ffa500": "orange", + "#ff4500": "orange red", + "#da70d6": "orchid", + "#eee8aa": "pale goldenrod", + "#98fb98": "pale green", + "#afeeee": "pale turquoise", + "#d87093": "pale violet red", + "#ffefd5": "papaya whip", + "#ffdab9": "peach puff", + "#cd853f": "peru", + "#ffc0cb": "pink", + "#dda0dd": "plum", + "#b0e0e6": "powder blue", + "#800080": "purple", + "#ff0000": "red", + "#bc8f8f": "rosy brown", + "#4169e1": "royal blue", + "#8b4513": "saddle brown", + "#fa8072": "salmon", + "#f4a460": "sandy brown", + "#2e8b57": "sea green", + "#fff5ee": "seashell", + "#a0522d": "sienna", + "#c0c0c0": "silver", + "#87ceeb": "sky blue", + "#6a5acd": "slate blue", + "#708090": "slate gray", + "#fffafa": "snow", + "#00ff7f": "spring green", + "#4682b4": "steel blue", + "#d2b48c": "tan", + "#008080": "teal", + "#d8bfd8": "thistle", + "#ff6347": "tomato", + "#40e0d0": "turquoise", + "#ee82ee": "violet", + "#f5deb3": "wheat", + "#ffffff": "white", + "#f5f5f5": "white smoke", + "#ffff00": "yellow", + "#9acd32": "yellow green", +} diff --git a/internal/app/clipboard.go b/internal/app/clipboard.go new file mode 100644 index 0000000..9e767f7 --- /dev/null +++ b/internal/app/clipboard.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "errors" + "git.stormux.org/storm/cthulhu/internal/platform/session" + "os" + "os/exec" + "time" +) + +const clipboardReadTimeout = 3 * time.Second + +func runClipboardWorker(ctx context.Context, requests <-chan struct{}, speech speechEnqueuer, read func(context.Context) (string, error)) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-ctx.Done(): + return + case <-requests: + requestCtx, cancel := context.WithTimeout(ctx, clipboardReadTimeout) + result, err := read(requestCtx) + cancel() + if ctx.Err() != nil { + return + } + message := "Error accessing clipboard" + if err == nil { + message = result + } + speech.Interrupt() + speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "clipboard"}) + } + } + }() + return done +} + +func readClipboardMessage(ctx context.Context) (string, error) { + executable, err := os.Executable() + if err != nil { + return "", err + } + command := exec.CommandContext(ctx, executable, "--clipboard-read") + command.Env = session.GTKEnvironment() + command.WaitDelay = time.Second + output, err := command.Output() + if err != nil { + return "", err + } + var result struct { + Message string `json:"message"` + } + if err := json.Unmarshal(output, &result); err != nil || result.Message == "" { + return "", errors.New("invalid clipboard helper response") + } + return result.Message, nil +} diff --git a/internal/app/clipboard_copy.go b/internal/app/clipboard_copy.go new file mode 100644 index 0000000..d1a5847 --- /dev/null +++ b/internal/app/clipboard_copy.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func runCopyFeedback(ctx context.Context, changes <-chan struct{}, source any, speech speechEnqueuer, live *liveSettings, ocr *ocrSession) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + var announced uint64 + for { + select { + case <-ctx.Done(): + return + case _, ok := <-changes: + if !ok { + return + } + activity := recentInput(source) + if ocr != nil && ocr.Reviewing() { + if activity.Clipboard == input.CopyAction { + announced = activity.Sequence + } + continue + } + if live != nil && live.Load().Reading.AutomaticallyReadClipboardChanges { + continue + } + if activity.Clipboard != input.CopyAction || activity.Sequence == 0 || activity.Sequence == announced { + continue + } + announced = activity.Sequence + speech.Enqueue(speechRequest{Text: "copied to clipboard", Role: "system", Cause: "clipboard"}) + } + } + }() + return done +} diff --git a/internal/app/clipboard_copy_test.go b/internal/app/clipboard_copy_test.go new file mode 100644 index 0000000..475f7b4 --- /dev/null +++ b/internal/app/clipboard_copy_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +func TestCopyFeedbackRequiresClipboardChangeAndNewCopyKey(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + changes := make(chan struct{}) + tracker := &input.IntentTracker{} + speech := &fakeSpeechQueue{spoken: make(chan string, 4), interrupt: make(chan struct{}, 4)} + done := runCopyFeedback(ctx, changes, tracker, speech, nil, nil) + checkSilent := func() { + t.Helper() + select { + case got := <-speech.spoken: + t.Fatalf("unexpected speech %q", got) + case <-time.After(30 * time.Millisecond): + } + } + change := func() { changes <- struct{}{} } + change() + checkSilent() + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "c")) + checkSilent() + change() + select { + case got := <-speech.spoken: + if got != "copied to clipboard" { + t.Fatalf("copy speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("copy was not announced") + } + change() + checkSilent() + tracker.Observe(input.NewKeyEvent(0, true, "c")) + change() + select { + case got := <-speech.spoken: + if got != "copied to clipboard" { + t.Fatalf("second copy speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("second copy was not announced") + } + tracker.Observe(input.NewKeyEvent(0, true, "v")) + change() + checkSilent() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("copy worker did not stop") + } +} + +func TestCopyFeedbackDoesNotRepeatOCRCopy(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + changes := make(chan struct{}) + tracker := &input.IntentTracker{} + speech := &fakeSpeechQueue{spoken: make(chan string, 2), interrupt: make(chan struct{}, 2)} + session := &ocrSession{cursor: ocr.NewCursor(ocr.Result{Text: "OCR"}), cancel: func() {}} + done := runCopyFeedback(ctx, changes, tracker, speech, nil, session) + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "c")) + changes <- struct{}{} + select { + case got := <-speech.spoken: + t.Fatalf("duplicate OCR copy feedback %q", got) + case <-time.After(30 * time.Millisecond): + } + session.mu.Lock() + session.cursor, session.cancel = nil, nil + session.mu.Unlock() + changes <- struct{}{} + select { + case got := <-speech.spoken: + t.Fatalf("late duplicate OCR copy feedback %q", got) + case <-time.After(30 * time.Millisecond): + } + tracker.Observe(input.NewKeyEvent(0, true, "c")) + changes <- struct{}{} + select { + case got := <-speech.spoken: + if got != "copied to clipboard" { + t.Fatal(got) + } + case <-time.After(time.Second): + t.Fatal("ordinary copy feedback was suppressed") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("copy worker did not stop") + } +} diff --git a/internal/app/clipboard_test.go b/internal/app/clipboard_test.go new file mode 100644 index 0000000..a0142aa --- /dev/null +++ b/internal/app/clipboard_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestClipboardWorkerAnnouncesContentAndAccessError(t *testing.T) { + for _, test := range []struct { + name string + read func(context.Context) (string, error) + wantText string + }{ + {"text", func(context.Context) (string, error) { + return "Text in clipboard sample", nil + }, "Text in clipboard sample"}, + {"error", func(context.Context) (string, error) { + return "", errors.New("unavailable") + }, "Error accessing clipboard"}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + requests := make(chan struct{}, 1) + speech := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)} + done := runClipboardWorker(ctx, requests, speech, test.read) + requests <- struct{}{} + select { + case got := <-speech.spoken: + if got != test.wantText { + t.Fatalf("speech = %q, want %q", got, test.wantText) + } + case <-time.After(time.Second): + t.Fatal("clipboard announcement missing") + } + if len(speech.interrupt) != 1 { + t.Fatal("clipboard announcement did not interrupt prior speech") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("clipboard worker did not stop") + } + }) + } +} diff --git a/internal/app/clipboard_watch.go b/internal/app/clipboard_watch.go new file mode 100644 index 0000000..06b20a1 --- /dev/null +++ b/internal/app/clipboard_watch.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/clipboard" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/platform/session" +) + +type clipboardScope struct { + settings *config.Settings + application, requested string + enabled bool +} + +func clipboard_scope(live *liveSettings, gate *focusGate) clipboardScope { + scope := clipboardScope{settings: live.current.Load()} + if name := live.application.Load(); name != nil { + scope.application = *name + } + if name := live.requestedApplication.Load(); name != nil { + scope.requested = *name + } + scope.enabled = scope.settings.Reading.AutomaticallyReadClipboardChanges && !gate.Locked() && !gate.Suspended() && + (scope.requested == "" || scope.requested == scope.application) + return scope +} + +func run_automatic_clipboard(ctx context.Context, logger *slog.Logger, live *liveSettings, gate *focusGate, speech speechEnqueuer) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + if live == nil { + return + } + // No helper or clipboard reads until an application enables the feature. + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for !clipboard_scope(live, gate).enabled { + select { + case <-ctx.Done(): + return + case <-live.clipboardChanges: + case <-ticker.C: + } + } + ticker.Stop() + if err := watch_clipboard(ctx, live, gate, speech); err != nil && ctx.Err() == nil { + logger.Warn("automatic clipboard reading unavailable", "error", err) + } + }() + return done +} + +func watch_clipboard(ctx context.Context, live *liveSettings, gate *focusGate, speech speechEnqueuer) error { + executable, err := os.Executable() + if err != nil { + return err + } + childCtx, cancel := context.WithCancel(ctx) + defer cancel() + command := exec.CommandContext(childCtx, executable, "--clipboard-watch") + command.Env = session.GTKEnvironment() + command.WaitDelay = time.Second + in, err := command.StdinPipe() + if err != nil { + return err + } + defer in.Close() + out, err := command.StdoutPipe() + if err != nil { + return err + } + defer out.Close() + var diagnostic preferencesStderr + command.Stderr = &diagnostic + if err := command.Start(); err != nil { + return err + } + defer func() { cancel(); in.Close(); out.Close(); command.Wait() }() + changes := make(chan clipboard.TextChange, 128) + failures := make(chan error, 1) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 4096), 1024*1024) + for scanner.Scan() { + var change clipboard.TextChange + if err := json.Unmarshal(scanner.Bytes(), &change); err != nil { + failures <- err + return + } + select { + case changes <- change: + case <-childCtx.Done(): + return + } + } + err := scanner.Err() + if err == nil { + err = io.EOF + } + failures <- err + }() + defer func() { cancel(); out.Close(); <-readerDone }() + return serve_clipboard_changes(ctx, live, gate, speech, json.NewEncoder(in), changes, failures) +} + +func serve_clipboard_changes(ctx context.Context, live *liveSettings, gate *focusGate, speech speechEnqueuer, encoder *json.Encoder, changes <-chan clipboard.TextChange, failures <-chan error) error { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + var previous clipboardScope + var epoch uint64 + var lastText string + update := func() error { + scope := clipboard_scope(live, gate) + if scope == previous { + return nil + } + previous = scope + epoch++ + lastText = "" + return encoder.Encode(clipboard.WatchSettings{Enabled: scope.enabled, Epoch: epoch}) + } + if err := update(); err != nil { + return err + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-failures: + return err + case <-live.clipboardChanges: + if err := update(); err != nil { + return err + } + case <-ticker.C: + // Check the screen-lock gate without polling clipboard contents. + if err := update(); err != nil { + return err + } + case change, ok := <-changes: + if !ok { + return errors.New("clipboard helper closed") + } + if change.Ready { + continue + } + if clipboard_scope(live, gate) != previous { + if err := update(); err != nil { + return err + } + } + if !previous.enabled || change.Epoch != epoch { + continue + } + if strings.TrimSpace(change.Text) != "" && change.Text != lastText { + speech.Enqueue(speechRequest{Text: change.Text, Role: "system", Cause: "clipboard", Append: true, ClipboardTimestamp: change.Timestamp}) + } + lastText = change.Text + } + } +} diff --git a/internal/app/clipboard_watch_test.go b/internal/app/clipboard_watch_test.go new file mode 100644 index 0000000..4fda302 --- /dev/null +++ b/internal/app/clipboard_watch_test.go @@ -0,0 +1,99 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/clipboard" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestAutomaticClipboardQueuesTextAndNormalInputCancels(t *testing.T) { + live := &liveSettings{} + settings := config.DefaultSettings() + if settings.Reading.AutomaticallyReadClipboardChanges { + t.Fatal("clipboard reading defaults on") + } + settings.Reading.AutomaticallyReadClipboardChanges = true + live.Store(settings) + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true // Inspect the real FIFO without a delivery goroutine. + changes := make(chan clipboard.TextChange, 6) + changes <- clipboard.TextChange{Text: "First", Epoch: 1, Timestamp: 10} + changes <- clipboard.TextChange{Text: "Second", Epoch: 1, Timestamp: 20} + changes <- clipboard.TextChange{Text: "", Epoch: 1, Timestamp: 30} + changes <- clipboard.TextChange{Text: "Third", Epoch: 1, Timestamp: 40} + close(changes) + var output bytes.Buffer + serve_clipboard_changes(context.Background(), live, nil, worker, json.NewEncoder(&output), changes, nil) + if len(worker.pending) != 3 { + t.Fatalf("clipboard queue = %+v", worker.pending) + } + for i, want := range []string{"First", "Second", "Third"} { + if worker.pending[i].Text != want || !worker.pending[i].Append { + t.Fatalf("clipboard request %d = %+v", i, worker.pending[i]) + } + } + if !worker.InterruptForInput(input.Event{Pressed: true, Name: "Control_L", Timestamp: 50}) || len(worker.pending) != 0 { + t.Fatal("normal input did not clear clipboard queue") + } + worker.Enqueue(speechRequest{Text: "Late old clipboard", Cause: "clipboard", Append: true, ClipboardTimestamp: 40}) + if len(worker.pending) != 0 { + t.Fatal("old asynchronous clipboard reply replayed after interruption") + } + worker.Enqueue(speechRequest{Text: "New clipboard", Cause: "clipboard", Append: true, ClipboardTimestamp: 60}) + if len(worker.pending) != 1 { + t.Fatal("new clipboard change suppressed after interruption") + } + worker.Enqueue(speechRequest{Text: "Same millisecond copy", Cause: "clipboard", Append: true, ClipboardTimestamp: 50}) + if len(worker.pending) != 2 { + t.Fatal("copy result in same server millisecond as input was suppressed") + } +} + +func TestAutomaticClipboardRejectsDisabledAndStaleApplications(t *testing.T) { + for _, tc := range []struct { + name string + enabled, pending bool + }{ + {"disabled", false, false}, {"switching", true, true}, {"stale epoch", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + live := &liveSettings{} + settings := config.DefaultSettings() + settings.Reading.AutomaticallyReadClipboardChanges = tc.enabled + live.Store(settings) + if tc.pending { + name := "Other Application" + live.requestedApplication.Store(&name) + } + changes := make(chan clipboard.TextChange, 1) + changes <- clipboard.TextChange{Text: "Do not speak", Epoch: 0} + close(changes) + speech := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)} + var output bytes.Buffer + serve_clipboard_changes(context.Background(), live, nil, speech, json.NewEncoder(&output), changes, nil) + if len(speech.spoken) != 0 || len(speech.interrupt) != 0 { + t.Fatal("disabled/stale clipboard was spoken") + } + }) + } +} + +func TestAutomaticClipboardDisabledStartsNoHelperAndStops(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + live := &liveSettings{clipboardChanges: make(chan struct{}, 1)} + live.Store(config.DefaultSettings()) + done := run_automatic_clipboard(ctx, slog.New(slog.DiscardHandler), live, nil, discardSpeech{}) + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("disabled clipboard worker did not stop") + } +} diff --git a/internal/app/combo_presentation.go b/internal/app/combo_presentation.go new file mode 100644 index 0000000..5c86732 --- /dev/null +++ b/internal/app/combo_presentation.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +// The event loop owns this history. Some toolkits repeat focus on the semantic +// combo when its internal selection changes; that is not entry into a control. +type comboPresentation struct { + focus atspi.ObjectID + value string +} + +func (history *comboPresentation) observe(event atspi.Event) (text string, update, duplicate bool) { + focusGain := isFocusGainEvent(event) + if event.Source.Role != "combo box" { + if focusGain { + history.focus, history.value = event.Source.ID(), "" + } + return "", false, false + } + if !focusGain && !isValueChangeEvent(event.Type) && + event.Type != atspi.AccessibleNameEvent && !isManagedDescendantEvent(event.Type) { + return "", false, false + } + text = presentation.ComboBoxSelectionText(event.Source) + update = !focusGain || event.Source.ID() == history.focus + duplicate = update && event.Source.ID() == history.focus && text == history.value + history.value = text + if focusGain { + history.focus = event.Source.ID() + } + return text, update, duplicate +} diff --git a/internal/app/combo_presentation_test.go b/internal/app/combo_presentation_test.go new file mode 100644 index 0000000..9fc8896 --- /dev/null +++ b/internal/app/combo_presentation_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func TestComboSelectionSpeaksValueWithoutRepeatingRoleSound(t *testing.T) { + for _, kind := range []string{atspi.FocusEvent, atspi.FocusedStateEvent, atspi.AccessibleNameEvent, atspi.AccessibleValueEvent, atspi.ValueChangedEvent} { + t.Run(kind, func(t *testing.T) { + queue := &modeSoundQueue{speech: &fakeSpeechQueue{spoken: make(chan string, 16)}, sounds: make(chan sound.Cue, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} + combo := atspi.Snapshot{BusName: ":1.70", Path: "/combo", Role: "combo box", Focused: true, ValueText: "bacon"} + focus := atspi.NewEvent(atspi.FocusEvent, combo) + adapter.events <- focus + adapter.events <- focus // Providers can repeat focus while the combo stays active. + for _, value := range []string{"hamburger", "steak", "hamburger", "bacon"} { + combo.ValueText = value + changed := atspi.NewEvent(kind, combo) + changed.Detail1 = 1 + changed.OriginPath = combo.Path + adapter.events <- changed + } + adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: combo.BusName, Path: "/other", Role: "entry", Name: "Other", Focused: true}) + adapter.events <- focus + close(adapter.events) + options := presentation.DefaultOptions() + options.EnableSound, options.PlaySoundForRole = true, true + options.RoleSoundPresentation = "sound_only" + processEventsWithInput(context.Background(), slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil, options) + close(queue.speech.spoken) + var got []string + for text := range queue.speech.spoken { + got = append(got, text) + } + want := []string{"bacon", "hamburger", "steak", "hamburger", "bacon", "Other, entry", "bacon"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("speech = %q, want %q", got, want) + } + if len(queue.sounds) != 2 { + t.Fatalf("got %d sounds, want only initial and returning focus", len(queue.sounds)) + } + for range 2 { + if cue := <-queue.sounds; cue.Name != "combobox" { + t.Fatalf("cue = %+v", cue) + } + } + }) + } +} diff --git a/internal/app/command_input_test.go b/internal/app/command_input_test.go new file mode 100644 index 0000000..77ceaf1 --- /dev/null +++ b/internal/app/command_input_test.go @@ -0,0 +1,109 @@ +// 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" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestRunPresentsLockStatesWithKeyEchoDisabled(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSound = false + settings.EnableBraille = false + settings.EnableKeyEcho = false + speaker := &fakeSpeaker{spoken: make(chan string, 4)} + commands := &fakeCommands{commands: make(chan input.CommandEvent), lockStates: make(chan input.LockState)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan error, 1) + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), Settings: &settings, + newCommands: func() commandListener { return commands }, + newSpeaker: func() Speaker { return speaker }, + newAdapter: func() Adapter { return &fakeAdapter{events: make(chan atspi.Event)} }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + defer func() { + cancel() + select { + case err := <-completed: + if err != nil { + t.Error(err) + } + case <-time.After(time.Second): + t.Error("reader did not stop") + } + }() + select { + case <-speaker.spoken: + case <-time.After(time.Second): + t.Fatal("missing startup speech") + } + for _, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} { + for _, locked := range []bool{true, false} { + state := input.LockState{Name: name, Locked: locked, Timestamp: 100} + select { + case commands.lockStates <- state: + case <-time.After(time.Second): + t.Fatal("lock event was not received") + } + select { + case text := <-speaker.spoken: + if text != state.Text() { + t.Fatalf("speech = %q, want %q", text, state.Text()) + } + case <-time.After(time.Second): + t.Fatal("lock state did not reach speech") + } + } + } +} + +func TestCommandSpeechSurvivesDelayedInitiatingInput(t *testing.T) { + for _, cause := range []string{"current-time", "current-date", "version"} { + t.Run(cause, func(t *testing.T) { + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true + worker.ProtectCommand(100) + worker.Enqueue(speechRequest{Text: "Command result", Role: "system", Cause: cause}) + for _, timestamp := range []uint32{80, 99, 100} { + if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: timestamp}) { + t.Fatalf("command interrupted by initiating input at %d", timestamp) + } + } + if len(worker.pending) != 1 || worker.generation != 0 { + t.Fatal("command speech was discarded") + } + if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) || len(worker.pending) != 0 { + t.Fatal("later input did not interrupt command speech") + } + }) + } +} + +func TestCommandInputProtectionOrdersTimestampsAcrossWrap(t *testing.T) { + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.ProtectCommand(^uint32(0) - 1) + worker.ProtectCommand(3) + worker.ProtectCommand(^uint32(0)) // An older queued notification cannot move the cutoff back. + for _, timestamp := range []uint32{^uint32(0), 2, 3} { + if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: timestamp}) { + t.Fatalf("old input at %d interrupted", timestamp) + } + } + for _, timestamp := range []uint32{4, 0} { + if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: timestamp}) { + t.Fatalf("new or untimed input at %d ignored", timestamp) + } + } +} diff --git a/internal/app/configured_speaker.go b/internal/app/configured_speaker.go new file mode 100644 index 0000000..a272848 --- /dev/null +++ b/internal/app/configured_speaker.go @@ -0,0 +1,295 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "sync" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +type configuredVoice interface { + Speaker + SpeakCharacter(context.Context, string) error + SpeakEcho(context.Context, string) error + SpeakAndWait(context.Context, string, func()) error + SpeakVoice(context.Context, string, string) error + SpeakVoiceAndWait(context.Context, string, string, func()) error + SpeakEchoCharacter(context.Context, string) error +} + +// Keep backend replacement on the preferences worker. Input cancellation can +// still reach the active backend while its replacement connects. +type configuredSpeaker struct { + mu sync.RWMutex + update sync.Mutex + active configuredVoice + socket string + settings config.Settings + closed bool +} + +func newConfiguredSpeaker(socket string, settings config.Settings) *configuredSpeaker { + return &configuredSpeaker{socket: socket, settings: settings} +} +func (speaker *configuredSpeaker) Connect(ctx context.Context) error { + return speaker.UpdateConfiguration(ctx, speaker.settings) +} +func (speaker *configuredSpeaker) UpdateConfiguration(ctx context.Context, settings config.Settings) error { + if !speaker.update.TryLock() { + return errors.New("speech settings update already in progress") + } + defer speaker.update.Unlock() + if err := config.Validate(settings); err != nil { + return err + } + speaker.mu.RLock() + current, closed := speaker.active, speaker.closed + speaker.mu.RUnlock() + if closed { + return errors.New("speech is closed") + } + // Reuse the existing descriptor when only serial voice parameters change. + if hardware, ok := current.(*hardwareSpeaker); ok && settings.SpeechServerFactory == "hardwarefactory" && + hardware.device == settings.HardwareSpeechDevice && hardware.baud == settings.HardwareSpeechBaudRate && + hardware.synth == settings.SpeechOutputModule { + if err := hardware.Cancel(ctx); err != nil { + return err + } + hardware.mu.Lock() + if err := ctx.Err(); err != nil { + hardware.mu.Unlock() + return err + } + hardware.settings = settings + hardware.mu.Unlock() + return nil + } + if software, ok := current.(*echoSpeaker); ok && settings.SpeechServerFactory != "hardwarefactory" { + return software.UpdateConfiguration(ctx, settings) + } + var candidate configuredVoice + if settings.SpeechServerFactory == "hardwarefactory" { + candidate = &hardwareSpeaker{device: settings.HardwareSpeechDevice, baud: settings.HardwareSpeechBaudRate, + synth: settings.SpeechOutputModule, settings: settings} + } else { + candidate = newEchoSpeaker(speaker.socket, settings) + } + if err := candidate.Connect(ctx); err != nil { + return err + } + speaker.mu.Lock() + if speaker.closed || ctx.Err() != nil { + speaker.mu.Unlock() + candidate.Close(context.Background()) + return errors.New("speech configuration was canceled") + } + if current != nil { + if err := current.Cancel(ctx); err != nil { + speaker.mu.Unlock() + candidate.Close(context.Background()) + return err + } + } + speaker.active = candidate + speaker.settings = settings + speaker.mu.Unlock() + // The new backend is committed. A retired connection's cleanup error must + // not report this successful swap as a rejected preferences transaction. + if current != nil { + _ = current.Close(context.Background()) + } + return nil +} +func (speaker *configuredSpeaker) voice() (configuredVoice, error) { + speaker.mu.RLock() + defer speaker.mu.RUnlock() + if speaker.active == nil || speaker.closed { + return nil, errors.New("speech is not connected") + } + return speaker.active, nil +} +func (speaker *configuredSpeaker) Speak(ctx context.Context, text string) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.Speak(ctx, text) +} +func (speaker *configuredSpeaker) SpeakEcho(ctx context.Context, text string) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.SpeakEcho(ctx, text) +} +func (speaker *configuredSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.SpeakVoice(ctx, text, kind) +} +func (speaker *configuredSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.SpeakVoiceAndWait(ctx, text, kind, began) +} +func (speaker *configuredSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.SpeakEchoCharacter(ctx, text) +} +func (speaker *configuredSpeaker) SpeakCharacter(ctx context.Context, text string) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.SpeakCharacter(ctx, text) +} +func (speaker *configuredSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { + voice, err := speaker.voice() + if err != nil { + return err + } + return voice.SpeakAndWait(ctx, text, began) +} + +func (speaker *configuredSpeaker) speak_uninterruptible(ctx context.Context, text string) error { + voice, err := speaker.voice() + if err != nil { + return err + } + reader, ok := voice.(interface { + speak_uninterruptible(context.Context, string) error + }) + if !ok { + return errors.New("speech backend does not report utterance completion") + } + return reader.speak_uninterruptible(ctx, text) +} +func (speaker *configuredSpeaker) Cancel(ctx context.Context) error { + speaker.mu.RLock() + voice := speaker.active + speaker.mu.RUnlock() + if voice == nil { + return nil + } + return voice.Cancel(ctx) +} +func (speaker *configuredSpeaker) Close(ctx context.Context) error { + speaker.update.Lock() + defer speaker.update.Unlock() + speaker.mu.Lock() + voice := speaker.active + speaker.active, speaker.closed = nil, true + speaker.mu.Unlock() + if voice == nil { + return nil + } + return voice.Close(ctx) +} + +type hardwareSpeaker struct { + mu sync.RWMutex + device, synth string + baud int + client *speech.Hardware + settings config.Settings +} + +func (speaker *hardwareSpeaker) Connect(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + client, err := speech.NewHardware(speaker.device, speaker.synth, speaker.baud) + if err != nil { + return err + } + speaker.client = client + return nil +} +func (speaker *hardwareSpeaker) Speak(ctx context.Context, text string) error { + return speaker.speak(ctx, text, false, "default") +} +func (speaker *hardwareSpeaker) SpeakEcho(ctx context.Context, text string) error { + return speaker.speak(ctx, text, true, "default") +} +func (speaker *hardwareSpeaker) SpeakCharacter(ctx context.Context, text string) error { + return speaker.speak_character(ctx, text, false) +} +func (speaker *hardwareSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { + return speaker.speak_character(ctx, text, true) +} +func (speaker *hardwareSpeaker) speak_character(ctx context.Context, text string, echo bool) error { + r, size := utf8.DecodeRuneInString(text) + kind := "default" + if size == len(text) && unicode.IsUpper(r) { + kind = "uppercase" + } + return speaker.speak(ctx, text, echo, kind) +} +func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { + return speaker.speak(ctx, text, false, kind) +} +func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error { + speaker.mu.RLock() + defer speaker.mu.RUnlock() + settings := speaker.settings + voice := config.EffectiveVoice(settings, "default") + if echo && settings.UseCustomEchoVoice { + if value := settings.EchoVoice.Rate; value != nil { + voice.Rate = value + } + if value := settings.EchoVoice.AveragePitch; value != nil { + voice.AveragePitch = value + } + if value := settings.EchoVoice.Gain; value != nil { + voice.Gain = value + } + } + settings.DefaultVoice = voice + voice = config.EffectiveVoice(settings, kind) + // Hardware has a single serial engine; echo uses that same synth with its + // custom numeric voice settings. Restore the default values each utterance. + if voice.Rate == nil { + value := 50.0 + voice.Rate = &value + } + if voice.AveragePitch == nil { + value := 5.0 + voice.AveragePitch = &value + } + if voice.Gain == nil { + value := 5.0 + voice.Gain = &value + } + return speaker.client.Speak(ctx, text, speech.HardwareVoice{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}) +} +func (speaker *hardwareSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { + return speaker.SpeakVoiceAndWait(ctx, text, "default", began) +} +func (speaker *hardwareSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error { + // These protocols provide no utterance-completion event. Match Python's + // queued Say All behavior; progress denotes transmission, not audible end. + if err := speaker.SpeakVoice(ctx, text, kind); err != nil { + return err + } + if began != nil { + began() + } + return nil +} +func (speaker *hardwareSpeaker) Cancel(ctx context.Context) error { return speaker.client.Cancel(ctx) } +func (speaker *hardwareSpeaker) Close(ctx context.Context) error { return speaker.client.Close(ctx) } diff --git a/internal/app/configured_speaker_native_test.go b/internal/app/configured_speaker_native_test.go new file mode 100644 index 0000000..ecfca04 --- /dev/null +++ b/internal/app/configured_speaker_native_test.go @@ -0,0 +1,90 @@ +//go:build linux && cgo + +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" + "syscall" + "testing" + "time" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func test_hardware_switch(t *testing.T, speaker *configuredSpeaker, software config.Settings) { + t.Helper() + fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK, 0600) + if err != nil { + t.Fatal(err) + } + master := os.NewFile(uintptr(fd), "hardware-speech-fixture") + defer master.Close() + var unlocked int32 + var number uint32 + if _, _, code := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlocked))); code != 0 { + t.Fatal(code) + } + if _, _, code := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); code != 0 { + t.Fatal(code) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + hardware := software + hardware.SpeechServerFactory = "hardwarefactory" + hardware.SpeechOutputModule = "litetalk" + hardware.HardwareSpeechDevice = fmt.Sprintf("/dev/pts/%d", number) + hardware.HardwareSpeechBaudRate = 9600 + rate, pitch, volume := 99.0, 9.0, 9.0 + hardware.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume} + if err := speaker.UpdateConfiguration(ctx, hardware); err != nil { + t.Fatal(err) + } + check := func(speak func(context.Context, string) error, text, want string) { + t.Helper() + if err := speak(ctx, text); err != nil { + t.Fatal(err) + } + master.SetReadDeadline(time.Now().Add(time.Second)) + buffer := make([]byte, len(want)) + if _, err := io.ReadFull(master, buffer); err != nil || string(buffer) != want { + t.Fatalf("serial=%q want=%q error=%v", buffer, want, err) + } + } + check(speaker.Speak, "Default", "\x019S\x0199P\x019VDefault\r") + check(speaker.SpeakCharacter, "A", "\x019S\x0199P\x019VA\r") + check(speaker.SpeakCharacter, "a", "\x019S\x0199P\x019Va\r") + + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), hardware, speaker) + values := preferences.FromSettings(hardware) + rateLow := 0.0 + values.Rate = &rateLow + if response := worker.apply(ctx, preferences.Request{Values: values}); !response.Accepted { + t.Fatal(response.Message) + } + check(speaker.Speak, "Applied", "\x18\x010S\x0199P\x019VApplied\r") + values.HardwareSpeechDevice = "/dev/cthulhu-missing-serial-fixture" + if response := worker.apply(ctx, preferences.Request{Values: values}); response.Accepted { + t.Fatal("unavailable serial device accepted") + } + if worker.settings.HardwareSpeechDevice != hardware.HardwareSpeechDevice { + t.Fatal("failed Apply committed a missing device") + } + check(speaker.Speak, "Retained", "\x010S\x0199P\x019VRetained\r") + if err := speaker.UpdateConfiguration(ctx, software); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(worker.settings.HardwareSpeechDevice, "/dev/pts/") { + t.Fatal("unexpected fixture device") + } +} diff --git a/internal/app/control_events_test.go b/internal/app/control_events_test.go new file mode 100644 index 0000000..009ccf9 --- /dev/null +++ b/internal/app/control_events_test.go @@ -0,0 +1,284 @@ +// 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" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestRepeatedControlFocusAfterStateChange(t *testing.T) { + for _, role := range []string{"radio button", "check box", "toggle button"} { + for _, mode := range []string{"speech_only", "sound_and_speech", "sound_only"} { + t.Run(role+"/"+mode, func(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} + frames := make(chan braille.Frame, 16) + state := &eventLoopState{} + state.presentationOptions = presentation.DefaultOptions() + state.presentationOptions.EnableSound = true + state.presentationOptions.PlaySoundForState = true + state.presentationOptions.RoleSoundPresentation = mode + logger := slog.New(slog.DiscardHandler) + handler := accessibilityEventHandler{state: state, logger: logger, brailleOutput: frameRecorder{frames: frames}} + presenter := eventPresenter{state: state, logger: logger, speech: queue} + present := func(event atspi.Event) bool { + if handler.acceptFocusedEvent(event, input.Activity{}) { + return false + } + presenter.speak(event) + return true + } + control := atspi.Snapshot{BusName: ":1.7", Path: "/option", Role: role, Name: "Some", RadioGroupName: "Punctuation Level"} + focus := atspi.NewEvent(atspi.FocusedStateEvent, control) + focus.Detail1 = 1 + present(focus) + <-queue.requests + <-frames + control.Checked = true + checked := atspi.NewEvent("object:state-changed:checked", control) + checked.Detail1 = 1 + present(checked) + changed := <-queue.requests + if mode == "sound_only" { + if changed.Text != "" || len(changed.Sound) != 1 { + t.Fatalf("state sound = %+v", changed) + } + } else { + want := map[string]string{"radio button": "selected", "check box": "checked", "toggle button": "pressed"}[role] + if changed.Text != want { + t.Fatalf("state speech = %q, want %q", changed.Text, want) + } + } + checkedFrame := <-frames + focus.Source = control + if present(focus) || len(queue.requests) != 0 { + t.Fatal("repeated focus after state change restarted the control announcement") + } + if frame := <-frames; frame.Text != checkedFrame.Text || !state.current.Checked || !state.brailleSnapshot.Checked { + t.Fatalf("repeated focus lost the checked state: %+v", frame) + } + // New semantics at the same identity still need an announcement. + focus.Source.Name = "Most" + if !present(focus) || len(queue.requests) != 1 { + t.Fatal("changed control name was suppressed") + } + <-queue.requests + other := atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/apply", Role: "button", Name: "Apply"}) + present(other) + <-queue.requests + if !present(focus) || len(queue.requests) != 1 { + t.Fatal("returning to the control was suppressed") + } + }) + } + } +} + +func TestRepeatedTerminalFocusRetainsPendingPresentation(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 10*time.Millisecond, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + terminal := atspi.Snapshot{BusName: ":1.7", Path: "/terminal", ApplicationName: "Fixture", Role: "terminal", Text: "Ready"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, terminal) + focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) + focused.Detail1 = 1 + adapter.events <- focused + select { + case got := <-queue.spoken: + if got != "terminal, Ready" { + t.Fatalf("terminal focus speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("repeated focus discarded pending terminal speech") + } +} + +func TestSettingsTreeSelectionSpeaksOnceAndIgnoresUnfocusedEdits(t *testing.T) { + for _, toolkit := range []string{"Gecko", "Chromium", "gtk"} { + t.Run(toolkit, func(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 32)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 32)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + item := atspi.Snapshot{BusName: ":1.7", Path: "/server", Name: "Server Settings", Role: "tree item", ToolkitName: toolkit, Selected: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, item) + selected := atspi.NewEvent(atspi.SelectedStateEvent, item) + selected.Detail1 = 1 + adapter.events <- selected + selection := atspi.NewEvent(atspi.SelectionChangedEvent, item) + selection.OriginPath = "/tree" + adapter.events <- selection + port := atspi.Snapshot{BusName: ":1.7", Path: "/port", Name: "Port", Role: "spin button", Editable: true, Text: "993"} + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, port, "993") + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, port, "993") + // An active-page live region must still announce its update. + live := atspi.Snapshot{BusName: ":1.7", Path: "/live", Role: "entry", Editable: true, LiveRegion: true} + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, live, "Connection status updated") + // Actual focus and subsequent edits to the port must still speak. + adapter.events <- atspi.NewEvent(atspi.FocusEvent, port) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, port, "4") + // Selection changes within the focused tree must remain usable. + adapter.events <- atspi.NewEvent(atspi.FocusEvent, item) + adapter.events <- selection + next := item + next.Path, next.Name = "/folders", "Copies & Folders" + selection.Source = next + adapter.events <- selection + adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/end", Role: "button", Name: "End"}) + for _, want := range []string{ + "Server Settings, tree item, selected", "Connection status updated", "Port, spin button, 993", "4", + "Server Settings, tree item, selected", "Copies & Folders, tree item, selected", "End, button", + } { + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + }) + } +} + +func TestControlFocusCaretDoesNotInterruptButArrowCaretSpeaks(t *testing.T) { + for _, role := range []string{"spin button", "button"} { + t.Run(role, func(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) + }() + defer func() { cancel(); <-done }() + control := atspi.Snapshot{BusName: ":1.7", Path: "/control", Name: "Port", Role: role, Editable: role == "spin button", Text: "993"} + tracker.Observe(input.NewKeyEvent(0, true, "Tab")) + adapter.events <- atspi.NewEvent(atspi.FocusEvent, control) + select { + case <-queue.spoken: + case <-time.After(time.Second): + t.Fatal("missing control focus speech") + } + adapter.events <- atspi.NewEvent(atspi.CaretEvent, control) + select { + case got := <-queue.spoken: + t.Fatalf("automatic caret interrupted control speech with %q", got) + case <-time.After(50 * time.Millisecond): + } + tracker.Observe(input.NewKeyEvent(0, true, "Right")) + control.CaretCharacter = "9" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, control) + select { + case got := <-queue.spoken: + if got != "9" { + t.Fatalf("arrow caret speech = %q, want 9", got) + } + case <-time.After(time.Second): + t.Fatal("missing arrow caret speech") + } + }) + } +} + +func TestEmbeddedObjectCaretDoesNotReplaceControlFocus(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + tracker := &input.IntentTracker{} + browser := newBrowserModeController(tracker, &modeKeys{}, nil) + browser.ready, browser.mode = true, review.BrowseMode + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + tracker.Observe(input.NewKeyEvent(0, true, "Tab")) + checkbox := atspi.Snapshot{BusName: ":1.7", Path: "/checkbox", Role: "check box", Name: "Use HTML", StatesKnown: true, Sensitive: true, Focusable: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, checkbox) + select { + case got := <-queue.spoken: + if got != "Use HTML, check box, not checked" { + t.Fatalf("checkbox focus speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("missing checkbox focus speech") + } + container := atspi.Snapshot{BusName: ":1.7", Path: "/panel", Role: "panel", Text: "\ufffc"} + adapter.events <- atspi.NewEvent(atspi.CaretEvent, container) + select { + case got := <-queue.spoken: + t.Fatalf("embedded-object caret interrupted checkbox with %q", got) + case <-time.After(50 * time.Millisecond): + } + checkbox.Checked = true + adapter.events <- atspi.NewEvent("object:state-changed:checked", checkbox) + select { + case got := <-queue.spoken: + if got != "checked" { + t.Fatalf("checkbox toggle speech = %q, want checked", got) + } + case <-time.After(time.Second): + t.Fatal("checkbox focus was lost after container caret") + } + cancel() + <-done + if browser.focus != checkbox.ID() { + t.Fatalf("container caret replaced browser focus: %v", browser.focus) + } +} + +func TestFocusedTreeSelectionStateChangesRemainAudible(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + item := atspi.Snapshot{BusName: ":1.7", Path: "/item", Role: "list item", Name: "Important", Selected: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, item) + item.Selected = false + adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, item) + item.Selected = true + selected := atspi.NewEvent(atspi.SelectedStateEvent, item) + selected.Detail1 = 1 + adapter.events <- selected + for _, want := range []string{"Important, list item, selected", "not selected", "selected"} { + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("selection state speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing selection state %q", want) + } + } +} diff --git a/internal/app/date_time.go b/internal/app/date_time.go new file mode 100644 index 0000000..6bf89ff --- /dev/null +++ b/internal/app/date_time.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/datetime" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// The command worker supplies local wall time and one immutable settings snapshot. +func presentDateTime(speech speechEnqueuer, settings config.Settings, command input.Command, now time.Time) { + text, cause := "", "current-time" + if command == input.PresentDate { + cause = "current-date" + text, _ = datetime.FormatDate(settings.DateFormat, now) + } else { + text, _ = datetime.FormatTime(settings.TimeFormat, now) + } + speech.Interrupt() + speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: cause}) +} diff --git a/internal/app/date_time_test.go b/internal/app/date_time_test.go new file mode 100644 index 0000000..1f5ba07 --- /dev/null +++ b/internal/app/date_time_test.go @@ -0,0 +1,141 @@ +package app + +import ( + "context" + "log/slog" + "path/filepath" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/datetime" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func TestDateTimeFormats(t *testing.T) { + now := time.Date(2026, 9, 17, 19, 5, 0, 0, time.UTC) + for _, tc := range []struct { + command input.Command + format, want string + }{ + {input.PresentTime, "locale", "19:05:00"}, + {input.PresentTime, "12-hour", "07:05 PM"}, + {input.PresentTime, "12-hour-seconds", "07:05:00 PM"}, + {input.PresentTime, "24-hour-seconds", "19:05:00"}, + {input.PresentTime, "24-hour-seconds-words", "19 hours, 05 minutes and 00 seconds"}, + {input.PresentTime, "24-hour", "19:05"}, + {input.PresentTime, "24-hour-words", "19 hours and 05 minutes"}, + {input.PresentDate, "locale", "09/17/26"}, + {input.PresentDate, "numbers-dm", "17/09"}, + {input.PresentDate, "numbers-md", "09/17"}, + {input.PresentDate, "numbers-dmy", "17/09/2026"}, + {input.PresentDate, "numbers-mdy", "09/17/2026"}, + {input.PresentDate, "numbers-ymd", "2026/09/17"}, + {input.PresentDate, "full-dm", "Thursday, 17 September"}, + {input.PresentDate, "full-md", "Thursday, September 17"}, + {input.PresentDate, "full-dmy", "Thursday, 17 September, 2026"}, + {input.PresentDate, "full-mdy", "Thursday, September 17, 2026"}, + {input.PresentDate, "full-ymd", "2026. September 17, Thursday"}, + {input.PresentDate, "abbreviated-dm", "Thu, 17 Sep"}, + {input.PresentDate, "abbreviated-md", "Thu, Sep 17"}, + {input.PresentDate, "abbreviated-dmy", "Thu, 17 Sep, 2026"}, + {input.PresentDate, "abbreviated-mdy", "Thu, Sep 17, 2026"}, + {input.PresentDate, "abbreviated-ymd", "2026. Sep 17, Thu"}, + } { + settings := config.DefaultSettings() + if tc.command == input.PresentTime { + settings.TimeFormat = tc.format + } else { + settings.DateFormat = tc.format + } + queue := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)} + presentDateTime(queue, settings, tc.command, now) + if got := <-queue.spoken; got != tc.want { + t.Errorf("%s: %q want %q", tc.format, got, tc.want) + } + if len(queue.interrupt) != 1 { + t.Fatal("command did not interrupt") + } + } +} + +func TestDateTimeOptionCounts(t *testing.T) { + now := time.Date(2026, 9, 17, 19, 5, 0, 0, time.UTC) + timeOptions := datetime.TimeOptions(now) + dateOptions := datetime.DateOptions(now) + wantTime := []string{ + "locale", "12-hour", "12-hour-seconds", "24-hour-seconds", + "24-hour-seconds-words", "24-hour", "24-hour-words", + } + wantDate := []string{ + "locale", "numbers-dm", "numbers-md", "numbers-dmy", "numbers-mdy", + "numbers-ymd", "full-dm", "full-md", "full-dmy", "full-mdy", + "full-ymd", "abbreviated-dm", "abbreviated-md", "abbreviated-dmy", + "abbreviated-mdy", "abbreviated-ymd", + } + if len(timeOptions) != len(wantTime) { + t.Fatalf("time options: got %d, want %d", len(timeOptions), len(wantTime)) + } + if len(dateOptions) != len(wantDate) { + t.Fatalf("date options: got %d, want %d", len(dateOptions), len(wantDate)) + } + for _, test := range []struct { + name string + options []datetime.Option + expected []string + }{ + {"time", timeOptions, wantTime}, + {"date", dateOptions, wantDate}, + } { + for index, option := range test.options { + if option.Value != test.expected[index] { + t.Fatalf("%s option %d = %q, want %q", test.name, index, + option.Value, test.expected[index]) + } + } + } + for _, option := range append(timeOptions, dateOptions...) { + if option.Value == "" || option.Label == "" { + t.Fatalf("empty option: %+v", option) + } + } +} + +func TestDateTimePreferencesLiveAndSaved(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + live := &liveSettings{} + live.Store(settings) + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.live = live + values := preferences.FromSettings(settings) + values.TimeFormat, values.DateFormat = "24-hour-words", "abbreviated-mdy" + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true, Close: true}) + if !response.Accepted || !response.Close { + t.Fatalf("apply: %+v", response) + } + loaded, err := config.Load(settings.Path) + if err != nil || loaded.TimeFormat != "24-hour-words" || loaded.DateFormat != "abbreviated-mdy" { + t.Fatalf("saved formats: %+v %v", loaded, err) + } + queue := &fakeSpeechQueue{spoken: make(chan string, 1)} + presentDateTime(queue, live.Load(), input.PresentTime, time.Date(2026, 9, 17, 0, 1, 0, 0, time.Local)) + if got := <-queue.spoken; got != "00 hours and 01 minutes" { + t.Fatalf("live time: %s", got) + } + for _, field := range []string{"time", "date"} { + bad := values + if field == "time" { + bad.TimeFormat = "invalid" + } else { + bad.DateFormat = "invalid" + } + if worker.apply(context.Background(), preferences.Request{Values: bad, Save: true}).Accepted { + t.Fatal("invalid format accepted") + } + } + if live.Load().TimeFormat != "24-hour-words" || live.Load().DateFormat != "abbreviated-mdy" { + t.Fatal("invalid draft changed live settings") + } +} diff --git a/internal/app/document_line_test.go b/internal/app/document_line_test.go new file mode 100644 index 0000000..b9c5de5 --- /dev/null +++ b/internal/app/document_line_test.go @@ -0,0 +1,313 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +type documentLineCaptureCall struct { + focus atspi.ObjectID + result chan *atspi.AccessibleTree +} + +type documentLineCapturer struct { + calls chan documentLineCaptureCall +} + +func (capturer *documentLineCapturer) capture(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + call := documentLineCaptureCall{focus: focus, result: make(chan *atspi.AccessibleTree, 1)} + capturer.calls <- call + select { + case tree := <-call.result: + return tree, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (capturer *documentLineCapturer) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, atspi.ObjectID{}) +} + +func (capturer *documentLineCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, focus) +} + +func (capturer *documentLineCapturer) CaptureDocument(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, focus) +} + +func (capturer *documentLineCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, focus) +} + +type documentLineKeys struct{} + +func (keys *documentLineKeys) SetBrowseNavigation(bool) {} + +func documentLineTree(t *testing.T, staticText string) *atspi.AccessibleTree { + t.Helper() + baseNode := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, path, role, "", "") + snapshot.BusName = ":1.42" + return atspi.TreeNode{Snapshot: snapshot, Children: children} + } + lineNode := func(path, role, text string, bounds atspi.Rectangle, caret bool) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, path, role, "", text) + snapshot.BusName = ":1.42" + geometry := atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + visible := atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{ + Start: 0, End: utf8.RuneCountInString(text), Text: text, Bounds: bounds, + }}} + if caret { + visible.CaretKnown, visible.CaretOffset = true, 1 + } + return atspi.TreeNode{Snapshot: snapshot, Geometry: geometry, Text: visible} + } + tree, err := atspi.NewAccessibleTree(atspi.NewObjectID(":1.42", "/root"), []atspi.TreeNode{ + baseNode("/root", "frame", atspi.NewObjectID(":1.42", "/document")), + baseNode("/document", "document web", atspi.NewObjectID(":1.42", "/static"), atspi.NewObjectID(":1.42", "/link")), + lineNode("/static", "static", staticText, atspi.Rectangle{X: 0, Y: 100, Width: 60, Height: 20}, false), + lineNode("/link", "link", "the link", atspi.Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, false), + }) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestProcessEventsSpeaksNewestDocumentCaretLine(t *testing.T) { + capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)} + worker, err := review.NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopContext, stopCancel := context.WithTimeout(context.Background(), time.Second) + defer stopCancel() + if err := worker.Stop(stopContext); err != nil { + t.Fatal(err) + } + }() + + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "Down")) + browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) + browser.mode = review.FocusMode + browser.ready = true + adapter := &fakeAdapter{events: make(chan atspi.Event)} + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0, nil, browser, nil, nil) + }() + + static := atspi.NewSnapshot("/static", "/static", "static", "", "First ") + static.BusName = ":1.42" + staticEvent := atspi.NewEvent(atspi.CaretEvent, static) + staticEvent.Detail1 = 1 + adapter.events <- staticEvent + firstCapture := <-capturer.calls + + link := atspi.NewSnapshot("/link", "/link", "link", "", "the link") + link.BusName = ":1.42" + linkEvent := atspi.NewEvent(atspi.CaretEvent, link) + linkEvent.Detail1 = 1 + adapter.events <- linkEvent + // Native capture observes the current document when it completes, not the + // event which originally caused the capture. + firstCapture.result <- documentLineTree(t, "Second ") + + select { + case got := <-speech.spoken: + if got != "Second, the link, link" { + t.Fatalf("document line speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for document line speech") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } + select { + case got := <-speech.spoken: + t.Fatalf("unexpected stale document line speech = %q", got) + default: + } +} + +func TestProcessEventsUsesCapturedLineInBrowseModeWithoutReviewWorker(t *testing.T) { + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "Down")) + browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) + browser.mode = review.BrowseMode + browser.ready = true + adapter := &fakeAdapter{events: make(chan atspi.Event)} + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + + retainedFocus := atspi.NewSnapshot("/old-heading", "Retained heading", "heading", "", "") + retainedFocus.BusName = ":1.42" + retainedFocus.Focusable = true + adapter.events <- atspi.NewEvent(atspi.FocusEvent, retainedFocus) + + snapshot := atspi.NewSnapshot("/paragraph", "", "paragraph", "", "Current browser line") + snapshot.BusName = ":1.42" + event := atspi.NewEvent(atspi.CaretEvent, snapshot) + event.Detail1 = 4 + adapter.events <- event + select { + case got := <-speech.spoken: + if got != "Current browser line" { + t.Fatalf("browse line speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for browse line speech") + } + if !browser.ready || browser.mode != review.BrowseMode { + t.Fatalf("retained DOM focus changed browse state: ready=%v mode=%v", browser.ready, browser.mode) + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestBlankBrowseCaretDoesNotCaptureWholeDocument(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 1)} + worker, err := review.NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "Down")) + browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) + browser.mode, browser.ready = review.BrowseMode, true + adapter := &fakeAdapter{events: make(chan atspi.Event)} + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + done := make(chan error, 1) + go func() { + done <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0, nil, browser, nil, nil) + }() + blank := atspi.NewSnapshot("/link", "", "link", "", "") + blank.BusName = ":1.42" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, blank) + select { + case <-capturer.calls: + t.Fatal("blank browse caret started full document text capture") + case <-time.After(50 * time.Millisecond): + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestBrowserCaretUpdatesStructuralPositionAndSounds(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)} + worker, err := review.NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "Down")) + browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) + browser.ready, browser.mode = true, review.BrowseMode + adapter := &fakeAdapter{events: make(chan atspi.Event)} + queue := &modeSoundQueue{speech: &fakeSpeechQueue{spoken: make(chan string, 4)}, sounds: make(chan sound.Cue, 4)} + options := presentation.DefaultOptions() + options.EnableSound, options.PlaySoundForRole = true, true + options.RoleSoundPresentation = "sound_only" + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0, nil, browser, nil, nil, options) + }() + defer func() { cancel(); <-done }() + snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/link", Role: "link", Text: "the link", Visited: true} + adapter.events <- atspi.NewEvent(atspi.CaretEvent, snapshot) + select { + case text := <-queue.speech.spoken: + if text != "the link" { + t.Fatalf("caret speech = %q", text) + } + case <-time.After(time.Second): + t.Fatal("missing caret speech") + } + select { + case cue := <-queue.sounds: + if cue.Name != "link_visited" { + t.Fatalf("caret cue = %#v", cue) + } + case <-time.After(time.Second): + t.Fatal("missing caret sound") + } + worker.MoveDocument(review.DocumentHeading, 0, false) + select { + case call := <-capturer.calls: + if call.focus != snapshot.ID() { + t.Fatalf("structural navigation anchored at %v, want caret %v", call.focus, snapshot.ID()) + } + case <-time.After(time.Second): + t.Fatal("structural navigation did not follow the caret") + } +} diff --git a/internal/app/document_native_test.go b/internal/app/document_native_test.go new file mode 100644 index 0000000..ca0c371 --- /dev/null +++ b/internal/app/document_native_test.go @@ -0,0 +1,976 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeBrowserDocumentCommands(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + keys := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + var focus atspi.Snapshot + for attempt := 0; attempt < 25 && focus.Name != "Browser foundation"; attempt++ { + keys("Tab") + timer := time.NewTimer(250 * time.Millisecond) + drain: + for { + select { + case event := <-adapter.Events(): + if isFocusEvent(event.Type) { + focus = event.Source + } + if focus.Name == "Browser foundation" { + break drain + } + case <-timer.C: + break drain + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + timer.Stop() + } + if focus.Name != "Browser foundation" { + t.Fatalf("fixture heading focus missing, last %q", focus.Name) + } + // Feed real owned native states into the policy without installing web-key + // grabs. These simple roles do not need the remaining derived traits. + tree, err := adapter.CaptureDocument(ctx, focus.ID()) + if err != nil { + t.Fatal(err) + } + ref, err := tree.Reference(focus.ID()) + if err != nil { + t.Fatal(err) + } + document, err := tree.Document(ctx, ref) + if err != nil { + t.Fatal(err) + } + type modeTarget struct{ name, role string } + modeCases := map[modeTarget]review.DocumentMode{ + {"Reading section", "heading"}: review.BrowseMode, + {"Ordinary unfocusable heading", "heading"}: review.BrowseMode, + {"Display name", "entry"}: review.FocusMode, + {"Details", "button"}: review.FocusMode, + {"Popup actions", "button"}: review.FocusMode, + {"Popup toggle", "toggle button"}: review.FocusMode, + {"Ordinary action", "button"}: review.BrowseMode, + } + for _, node := range document.Nodes { + target := modeTarget{node.Snapshot.Name, node.Snapshot.Role} + want, ok := modeCases[target] + if !ok { + continue + } + owner, err := tree.DocumentContext(node.Reference) + if err != nil { + t.Fatal(err) + } + var mode review.DocumentModeState + if got := mode.Update(review.ModeInput{Context: owner}, review.DefaultModeSettings()); got != want { + t.Fatalf("native mode for %s (%s): got %v want %v", node.Snapshot.Name, node.Snapshot.Role, got, want) + } + delete(modeCases, target) + } + if len(modeCases) != 0 { + t.Fatalf("missing native mode fixtures: %v", modeCases) + } + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.SyncFocus(focus.ID()) + commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := commands.Start(ctx); err != nil { + t.Fatal(err) + } + defer commands.Stop(context.Background()) + // This fixture drives the Xorg listener directly. Install the same browse + // bindings the application requests for an owned web document. + commands.SetBrowseNavigation(true) + browseDeadline := time.NewTimer(time.Second) + defer browseDeadline.Stop() + browseApplied := time.NewTicker(10 * time.Millisecond) + defer browseApplied.Stop() + for !commands.BrowseNavigationEnabled() { + select { + case <-browseDeadline.C: + t.Fatal("browse bindings were not applied") + case <-browseApplied.C: + } + } + queue := &fakeSpeechQueue{spoken: make(chan string, 1)} + reuseDocument := false + checkCommand := func(chord, want string) { + t.Helper() + keys(chord) + var command input.Command + select { + case event := <-commands.Commands(): + command = event.Command + case err := <-commands.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + target, level, backwards := documentReviewCommand(command) + if target == 0 { + t.Fatalf("unexpected command %s", command) + } + if err := worker.MoveDocument(target, level, backwards); err != nil { + t.Fatal(err) + } + var result review.Result + select { + case result = <-worker.Results(): + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if result.Err != nil { + t.Fatal(result.Err) + } + if result.CaretErr != nil { + t.Fatal(result.CaretErr) + } + if reuseDocument && result.CaptureReason != nil { + t.Fatalf("unchanged page recaptured for %s: %v (%s)", chord, result.CaptureReason, result.CaptureDuration) + } + if result.Moved { + worker.SyncFocus(result.DocumentPosition.Snapshot.ID()) + } + presentDocumentResult(slog.New(slog.DiscardHandler), queue, result, presentation.Options{}) + got := <-queue.spoken + if !strings.HasPrefix(got, want) { + t.Fatalf("%s: speech %q want %q", chord, got, want) + } + t.Logf("%s: %s (elapsed %s, capture %s)", chord, got, result.Elapsed, result.CaptureDuration) + } + for index, tc := range []struct{ chord, want string }{ + {"h", "Reading section, heading level 2"}, + {"h", "Ordinary unfocusable heading, heading level 2"}, + {"h", "Embedded heading, heading level 3"}, + {"h", "Browser foundation, heading level 1. Wrapping to top."}, + {"shift+h", "Embedded heading, heading level 3. Wrapping to bottom."}, + {"ctrl+shift+Insert+k", "Example link"}, + {"ctrl+Insert+k", "Example link, link. Wrapping to top."}, + } { + checkCommand(tc.chord, tc.want) + // Brave can retire the embedded srcdoc object during frame load. + // Cache reuse is deterministic only across the parent headings. + reuseDocument = index < 1 + } + reuseDocument = false + keys("ctrl+r") + for attempt := 0; attempt < 25; attempt++ { + keys("Tab") + timer := time.NewTimer(250 * time.Millisecond) + waitFocus: + for { + select { + case event := <-adapter.Events(): + if isFocusEvent(event.Type) && event.Source.Name == "Browser foundation" && event.Source.ID() != focus.ID() { + timer.Stop() + worker.SyncFocus(event.Source.ID()) + checkCommand("h", "Reading section, heading level 2") + checkCommand("shift+h", "Browser foundation, heading level 1") + return + } + case <-timer.C: + break waitFocus + case <-ctx.Done(): + timer.Stop() + t.Fatal(ctx.Err()) + } + } + } + t.Fatal("reloaded heading focus missing") +} + +func TestNativeBrowserEditing(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + listener := xorg.New() + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + var selectedCompletion atomic.Bool + var browserNameChanged atomic.Bool + var latestCaret atomic.Pointer[atspi.Event] + observed := &browserEventProbe{Adapter: adapter, events: make(chan atspi.Event, 64)} + relayDone := make(chan struct{}) + go func() { + defer close(relayDone) + for { + select { + case event := <-adapter.Events(): + if event.Type == atspi.AccessibleNameEvent && event.Source.Name == "Cthulhu Browser Probe updated" && event.Source.ToolkitName == "Chromium" { + browserNameChanged.Store(true) + } + if event.Type == atspi.CaretEvent { + latestCaret.Store(&event) + } + if event.Type == atspi.TextInsertedEvent && event.Source.Autocomplete && event.Source.SelectionEnd > event.Source.SelectionStart { + selectedCompletion.Store(true) + } + select { + case observed.events <- event: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + defer func() { cancel(); <-relayDone }() + queue := &fakeSpeechQueue{spoken: make(chan string, 128)} + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil) + }() + defer func() { cancel(); <-done }() + keys := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + collect := func() []string { + t.Helper() + timer := time.NewTimer(180 * time.Millisecond) + defer timer.Stop() + var result []string + for { + select { + case text := <-queue.spoken: + result = append(result, text) + case <-timer.C: + return result + case <-ctx.Done(): + t.Fatal(ctx.Err()) + return nil + } + } + } + keys("ctrl+l") + collect() + for _, letter := range []string{"c", "t", "h"} { + keys(letter) + got := collect() + if len(got) != 1 || got[0] != letter { + t.Fatalf("typing %q: %q", letter, got) + } + } + keys("Down") + got := collect() + if len(got) != 1 || !strings.Contains(got[0], "list item") { + t.Fatalf("explicit suggestion navigation: %q", got) + } + keys("Escape") + keys("Escape") + keys("Escape") + collect() + // Exercise the browser's native caret against non-editable page text. + keys("F7") + collect() + keys("Return") + collect() + foundPage := false + for attempt := 0; attempt < 30 && !foundPage; attempt++ { + keys("Tab") + for _, text := range collect() { + if strings.Contains(text, "Browser foundation, heading") { + foundPage = true + } + } + } + if !foundPage { + t.Fatal("could not focus the page heading") + } + keys("ctrl+Home") + collect() + keys("Down") + collect() // The focusable second heading may announce its role as well. + keys("Down") + got = collect() + if len(got) == 0 || got[len(got)-1] != "Plain document text with Café and 猫." { + t.Fatalf("native page line navigation: %q", got) + } + keys("ctrl+Right") + got = collect() + if len(got) != 1 || got[0] != "Plain" { + t.Fatalf("native word navigation: %q", got) + } + keys("Right") + got = collect() + if len(got) != 1 || got[0] != "d" { + t.Fatalf("native character navigation: %q", got) + } + keys("End") + collect() + if event := latestCaret.Load(); event == nil || event.Source.Text != "Plain document text with Café and 猫." { + t.Fatalf("line snapshot at end of browser text: %+v", event) + } + keys("Home") + collect() + keys("Down") + collect() + keys("Down") + got = collect() + if len(got) != 1 || got[0] != "• Registered: September 2026" { + t.Fatalf("embedded browser line text: %q", got) + } + keys("F8") + if got := collect(); len(got) != 0 || !browserNameChanged.Load() { + t.Fatalf("browser title update: speech=%q, native event=%v", got, browserNameChanged.Load()) + } + + // Seed a typed browser URL locally, then replace it with a prefix. This + // exercises real selected inline completion without external networking. + keys("ctrl+l") + collect() + if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "--delay", "20", "brave://version").CombinedOutput(); err != nil { + t.Fatalf("type URL: %v %s", err, out) + } + collect() + keys("Return") + collect() + keys("ctrl+l") + collect() + selectedCompletion.Store(false) + for _, letter := range []string{"b", "r", "a"} { + keys(letter) + got = collect() + want := letter + if letter == "b" { + want = "brave://version" + } + if len(got) != 1 || got[0] != want { + t.Fatalf("inline completion typing %q: %q, want %q", letter, got, want) + } + } + if !selectedCompletion.Load() { + t.Fatal("inline completion fixture did not produce a selected completion") + } +} + +type browserEventProbe struct { + *atspi.Adapter + events chan atspi.Event +} + +func (probe *browserEventProbe) Events() <-chan atspi.Event { return probe.events } + +type readAllProbeQueue struct { + *fakeSpeechQueue + reading chan speechRequest +} + +type browserBrailleProbe struct{ frames chan braille.Frame } + +func (probe browserBrailleProbe) EnqueueFrame(frame braille.Frame) { + select { + case probe.frames <- frame: + default: + // Like the real helper, retain recent output without blocking input + // while browser chrome produces many updates before an assertion. + select { + case <-probe.frames: + default: + } + select { + case probe.frames <- frame: + default: + } + } +} + +func (queue *readAllProbeQueue) Enqueue(request speechRequest) { + if request.Cause == "read-all" { + queue.reading <- request + return + } + queue.fakeSpeechQueue.Enqueue(request) +} + +func TestNativeBrowserModes(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + listener := xorg.New() + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := commands.Start(ctx); err != nil { + t.Fatal(err) + } + defer commands.Stop(context.Background()) + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + worker.SetDocumentBrailleEnabled(true) + if err = worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + browser := newBrowserModeController(listener, commands, adapter) + if err = browser.Start(ctx); err != nil { + t.Fatal(err) + } + defer browser.Stop(context.Background()) + queue := &readAllProbeQueue{fakeSpeechQueue: &fakeSpeechQueue{spoken: make(chan string, 256)}, reading: make(chan speechRequest, 1)} + brailleFrames := make(chan braille.Frame, 256) + // Shift+Down preserves a visual x position, so its character endpoint + // depends on fonts and browser layout. Compare speech with the actual + // native range rather than a hard-coded paragraph prefix. + nativeSelections := make(chan atspi.Snapshot, 16) + events := make(chan atspi.Event, 64) + probe := &browserEventProbe{Adapter: adapter, events: events} + probeDone := make(chan struct{}) + go func() { + defer close(probeDone) + for { + select { + case <-ctx.Done(): + return + case event := <-adapter.Events(): + if event.Type == atspi.CaretEvent && event.Source.Role == "paragraph" && + event.Source.TextCaretKnown && event.Source.SelectionEnd > event.Source.SelectionStart { + select { + case nativeSelections <- event.Source: + default: + } + } + select { + case events <- event: + case <-ctx.Done(): + return + } + } + } + }() + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: queue, braille: browserBrailleProbe{frames: brailleFrames}}, probe, nil, worker, 20*time.Millisecond, nil, browser, nil, nil) + }() + commandDone := make(chan struct{}) + go func() { + defer close(commandDone) + for { + select { + case <-ctx.Done(): + return + case event := <-commands.Commands(): + command := event.Command + if command == input.ToggleDocumentMode || command == input.StickyFocusMode || command == input.StickyBrowseMode { + browser.toggles <- command + } else if target, level, backwards := documentReviewCommand(command); target != 0 { + worker.MoveDocument(target, level, backwards) + } else if caretCommand := documentCaretCommand(command); caretCommand != 0 { + worker.MoveDocumentCaret(caretCommand) + } else if unit, step := flatReviewCommand(command); unit != 0 { + worker.ReadFlat(unit, step) + } else if command == input.ReadAll { + worker.ReadAll(ctx) + } else if command == input.DocumentActivate { + worker.ActivateDocumentCaret() + } + } + } + }() + defer func() { cancel(); <-done; <-commandDone; <-probeDone }() + keys := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + wait := func(want string) { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + var heard []string + for { + select { + case text := <-queue.spoken: + heard = append(heard, text) + if strings.Contains(text, "Could not") { + t.Fatalf("navigation failure: %s", text) + } + if text == want { + t.Logf("heard %s", text) + return + } + case err := <-commands.Failures(): + t.Fatal(err) + case <-timer.C: + t.Fatalf("missing %q; heard %q", want, heard) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + waitPrefix := func(want string) { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + var heard []string + for { + select { + case text := <-queue.spoken: + heard = append(heard, text) + if strings.HasPrefix(text, want) { + t.Logf("heard %s", text) + return + } + case err := <-commands.Failures(): + t.Fatal(err) + case <-timer.C: + t.Fatalf("missing %q; heard %q", want, heard) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + browseReady := func() { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for !commands.BrowseNavigationEnabled() { + select { + case <-ticker.C: + case <-timer.C: + t.Fatal("browse grabs did not become ready") + } + } + } + waitBraille := func(text string, cursor int) braille.Frame { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + var last braille.Frame + for { + select { + case frame := <-brailleFrames: + last = frame + if frame.Text == text && frame.Cursor == cursor { + // A frame can arrive before the mode controller restores + // native browse grabs after the preceding caret placement. + browseReady() + return frame + } + case <-timer.C: + t.Fatalf("missing braille %q cursor %d; last frame %+v", text, cursor, last) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + // This proof also runs by itself. Enable native caret browsing so Down + // moves in document content after structural navigation. + keys("F7") + keys("Return") + // Reload resets focus; Tab reaches the fixture's first focusable heading. + keys("ctrl+l") + fixture, err := filepath.Abs("../../tests/browser.html") + if err != nil { + t.Fatal(err) + } + if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "file://"+fixture).CombinedOutput(); err != nil { + t.Fatalf("load fixture: %v %s", err, out) + } + keys("Return") + waitPrefix("Cthulhu Browser Probe") + // Browser chrome can insert a tab-strip stop after a reload. Follow the + // actual focus announcements until the first document heading is reached. + foundHeading := false + for attempt := 0; attempt < 20 && !foundHeading; attempt++ { + keys("Tab") + timer := time.NewTimer(400 * time.Millisecond) + searchHeading: + for { + select { + case text := <-queue.spoken: + if text == "Browser foundation, heading level 1" { + foundHeading = true + break searchHeading + } + case <-timer.C: + break searchHeading + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + timer.Stop() + } + if !foundHeading { + t.Fatal("could not focus first document heading after reload") + } + browseReady() + keys("h") + wait("Reading section, heading level 2") + if err := worker.ReadFlat(review.Line, 0); err != nil { + t.Fatal(err) + } + wait("Reading section") + // Closing a second tab must restore browse keys for the original page. + keys("ctrl+t") + keys("ctrl+l") + waitPrefix("Address and search bar") + tabFixture, err := filepath.Abs("../../tests/browser-next.html") + if err != nil { + t.Fatal(err) + } + if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "file://"+tabFixture).CombinedOutput(); err != nil { + t.Fatalf("load second tab: %v %s", err, out) + } + keys("Return") + waitPrefix("Cthulhu Second Page") + keys("ctrl+w") + wait("Reading section, heading level 2") + browseReady() + // Page changes must reset the virtual position without a Tab/arrow nudge. + // Exercise both an address-bar load and activation of a real page link. + keys("ctrl+l") + nextFixture, err := filepath.Abs("../../tests/browser-next.html") + if err != nil { + t.Fatal(err) + } + if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "file://"+nextFixture).CombinedOutput(); err != nil { + t.Fatalf("load second fixture: %v %s", err, out) + } + keys("Return") + waitPrefix("Cthulhu Second Page") + browseReady() + keys("KP_Add") + select { + case request := <-queue.reading: + var text []string + for _, part := range request.ReadAll { + text = append(text, part.Text) + } + if got := strings.Join(text, "|"); got != "Fresh page heading|heading level 1|Fresh page content.|Return to browser probe|link" { + t.Fatalf("read all = %q, error speech = %q", got, request.Text) + } + case <-time.After(3 * time.Second): + t.Fatal("keypad Plus did not produce read all") + } + // Native Tab may select a link before any command captures a browse cursor. + keys("Tab") + wait("Return to browser probe, link") + browseReady() + keys("Return") + waitPrefix("Cthulhu Browser Probe") + browseReady() + keys("h") + wait("Browser foundation, heading level 1") + keys("h") + wait("Reading section, heading level 2") + browseReady() + keys("Home") + wait("R") + keys("shift+End") + wait("Reading section selected") + keys("shift+Down") + select { + case snapshot := <-nativeSelections: + paragraph := []rune("Plain document text with Café and 猫.") + if snapshot.SelectionStart != 0 || snapshot.SelectionEnd > len(paragraph) || + snapshot.BrailleCaret != snapshot.SelectionEnd { + t.Fatalf("heading-to-paragraph selection endpoints: %+v", snapshot) + } + selected := string(paragraph[:snapshot.SelectionEnd]) + if snapshot.SelectionText != selected { + t.Fatalf("native selected text = %q; range 0:%d = %q", snapshot.SelectionText, snapshot.SelectionEnd, selected) + } + t.Logf("native paragraph selection 0:%d, caret %d: %q", snapshot.SelectionEnd, snapshot.BrailleCaret, selected) + wait(strings.Join(strings.Fields(selected), " ") + " selected") + case <-time.After(3 * time.Second): + t.Fatal("Shift+Down did not select text in the paragraph") + } + keys("Home") + wait("P") + keys("Up") + wait("Reading section, heading level 2") + keys("Down") + wait("Plain document text with Café and 猫.") + waitBraille("Plain document text with Café and 猫.", 0) + browseReady() + keys("k") + wait("Example link, link") + keys("shift+F10") + wait("menu") + keys("Down") + waitPrefix("Open link in new tab") + keys("Down") + waitPrefix("Open link in new window") + keys("Escape") + browseReady() + keys("Home") + wait("E") + keys("ctrl+Right") + wait("Example, link") + keys("Return") + keys("h") + wait("Ordinary unfocusable heading, heading level 2") + browseReady() + keys("Down") + wait("Text refreshed after the ordinary heading.") + waitBraille("Text refreshed after the ordinary heading.", 0) + keys("Right") + wait("e") + waitBraille("Text refreshed after the ordinary heading.", 1) + keys("Home") + wait("T") + waitBraille("Text refreshed after the ordinary heading.", 0) + keys("ctrl+Right") + wait("Text") + waitBraille("Text refreshed after the ordinary heading.", 4) + keys("ctrl+Right") + wait("refreshed") + keys("ctrl+Left") + wait("refreshed") + keys("Home") + wait("T") + routeDisplay := &brailleWorker{document: worker, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}} + routeBrowse := func(frame braille.Frame, offset int) { + t.Helper() + if frame.Document == nil || len(frame.Routes) == 0 { + t.Fatalf("browse frame has no document routes: %+v", frame) + } + routeDisplay.EnqueueFrame(frame) + routeDisplay.route(ctx, braille.RoutingEvent{ID: routeDisplay.current().ID, Offset: offset}) + } + routeBrowse(waitBraille("Text refreshed after the ordinary heading.", 0), 1) + waitBraille("Text refreshed after the ordinary heading.", 1) + keys("Right") + wait("x") + waitBraille("Text refreshed after the ordinary heading.", 2) + keys("Home") + wait("T") + checkSelectionBraille := func(cursor, selected int) braille.Frame { + t.Helper() + frame := waitBraille("Text refreshed after the ordinary heading. $l", cursor) + for index := 0; index < 3; index++ { + want := byte(0) + if index < selected { + want = 0xc0 + } + if len(frame.Dots) <= index || frame.Dots[index] != want { + t.Fatalf("selection braille dots = %v; selected characters = %d", frame.Dots, selected) + } + } + return frame + } + keys("shift+Right") + wait("T selected") + checkSelectionBraille(1, 1) + keys("shift+Right") + wait("e selected") + checkSelectionBraille(2, 2) + keys("shift+Left") + wait("e unselected") + selectionFrame := checkSelectionBraille(1, 1) + // Routing a native selection frame clears selection and updates Go's cursor. + routeBrowse(selectionFrame, 1) + waitBraille("Text refreshed after the ordinary heading.", 1) + // Resume Go navigation from the native endpoint, then reverse it. + keys("Right") + wait("x") + waitBraille("Text refreshed after the ordinary heading.", 2) + keys("Left") + wait("e") + waitBraille("Text refreshed after the ordinary heading.", 1) + keys("Home") + wait("T") + for _, text := range []string{"e", "x", "t", "space"} { + keys("Right") + wait(text) + } + keys("End") + wait(".") + keys("ctrl+Left") + wait("heading") + keys("shift+h") + wait("Ordinary unfocusable heading, heading level 2") + keys("shift+h") + wait("Reading section, heading level 2") + keys("ctrl+Home") + wait("Browser foundation, heading level 1") + // The next native Tab must follow the new caret position, not the old link. + keys("Tab") + wait("Reading section, heading level 2") + browseReady() + keys("b") + waitPrefix("Details, button") + keys("shift+x") + waitPrefix("Remember choice, check box") + keys("shift+l") + wait("Example list, 2 items") + // The separator follows the list in document order, so this is forward + // navigation. Shift+S correctly reports the previous-separator boundary. + keys("s") + waitPrefix("Section separator, separator") + keys("k") + wait("Example link, link") + keys("Tab") + wait("Focus mode") + keys("h") + wait("h") + keys("k") + wait("k") + keys("Insert+a") + wait("Browse mode") + browseReady() + keys("h") + wait("Ordinary unfocusable heading, heading level 2") + keys("Insert+a") + wait("Focus mode") + keys("Insert+a") + wait("Focus mode is sticky.") + keys("Insert+a") + wait("Browse mode is sticky.") + // Separate ordinary toggles from the 500 ms multi-press sequence. + time.Sleep(550 * time.Millisecond) + keys("Insert+a") + wait("Focus mode") + time.Sleep(550 * time.Millisecond) + keys("Insert+a") + wait("Browse mode") + keys("ctrl+l") + // The chrome focus presentation is not stable across browser releases; + // Where Am I isn't needed to establish pass-through: typing must be heard. + keys("h") + wait("h") + keys("Escape") + keys("Escape") + found := false + for attempt := 0; attempt < 25 && !found; attempt++ { + keys("Tab") + timer := time.NewTimer(200 * time.Millisecond) + nextFocus: + for { + select { + case text := <-queue.spoken: + if text == "Browser foundation, heading level 1" { + found = true + break nextFocus + } + case <-timer.C: + break nextFocus + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + timer.Stop() + } + if !found { + t.Fatal("could not return to page heading from chrome") + } + browseReady() + keys("h") + wait("Reading section, heading level 2") + readAll := func() speechRequest { + t.Helper() + keys("KP_Add") + select { + case request := <-queue.reading: + if len(request.ReadAll) == 0 { + t.Fatalf("read all failed: %q", request.Text) + } + for _, part := range request.ReadAll { + if strings.Contains(part.Text, "private-probe-value") { + t.Fatal("read all exposed password text") + } + } + return request + case <-time.After(3 * time.Second): + t.Fatal("read all did not complete") + return speechRequest{} + } + } + reading := readAll() + if len(reading.ReadAll) < 3 || reading.ReadAll[2].Text != "Plain document text with Café and 猫." { + t.Fatal("reading sequence did not continue after the heading text and role") + } + // A real backend invokes this only upon SSIP BEGIN. Resume should use + // the spoken line rather than the original heading or end of the page. + reading.ReadAll[2].Began() + resumed := readAll() + if resumed.ReadAll[0].Text != "Plain document text with Café and 猫." { + t.Fatalf("resumed from %q", resumed.ReadAll[0].Text) + } +} diff --git a/internal/app/document_review.go b/internal/app/document_review.go new file mode 100644 index 0000000..7db0ba1 --- /dev/null +++ b/internal/app/document_review.go @@ -0,0 +1,441 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func documentReviewCommand(command input.Command) (review.DocumentTarget, int, bool) { + switch command { + case input.DocumentNextHeading: + return review.DocumentHeading, 0, false + case input.DocumentPreviousHeading: + return review.DocumentHeading, 0, true + case input.DocumentNextHeadingAtLevel1: + return review.DocumentHeading, 1, false + case input.DocumentPreviousHeadingAtLevel1: + return review.DocumentHeading, 1, true + case input.DocumentNextHeadingAtLevel2: + return review.DocumentHeading, 2, false + case input.DocumentPreviousHeadingAtLevel2: + return review.DocumentHeading, 2, true + case input.DocumentNextHeadingAtLevel3: + return review.DocumentHeading, 3, false + case input.DocumentPreviousHeadingAtLevel3: + return review.DocumentHeading, 3, true + case input.DocumentNextHeadingAtLevel4: + return review.DocumentHeading, 4, false + case input.DocumentPreviousHeadingAtLevel4: + return review.DocumentHeading, 4, true + case input.DocumentNextHeadingAtLevel5: + return review.DocumentHeading, 5, false + case input.DocumentPreviousHeadingAtLevel5: + return review.DocumentHeading, 5, true + case input.DocumentNextHeadingAtLevel6: + return review.DocumentHeading, 6, false + case input.DocumentPreviousHeadingAtLevel6: + return review.DocumentHeading, 6, true + case input.DocumentNextLink: + return review.DocumentLink, 0, false + case input.DocumentPreviousLink: + return review.DocumentLink, 0, true + case input.DocumentNextButton: + return review.DocumentButton, 0, false + case input.DocumentPreviousButton: + return review.DocumentButton, 0, true + case input.DocumentNextCheckBox: + return review.DocumentCheckBox, 0, false + case input.DocumentPreviousCheckBox: + return review.DocumentCheckBox, 0, true + case input.DocumentNextList: + return review.DocumentList, 0, false + case input.DocumentPreviousList: + return review.DocumentList, 0, true + case input.DocumentNextSeparator: + return review.DocumentSeparator, 0, false + case input.DocumentPreviousSeparator: + return review.DocumentSeparator, 0, true + case input.DocumentNextBlockQuote: + return review.DocumentBlockQuote, 0, false + case input.DocumentPreviousBlockQuote: + return review.DocumentBlockQuote, 0, true + case input.DocumentNextComboBox: + return review.DocumentComboBox, 0, false + case input.DocumentPreviousComboBox: + return review.DocumentComboBox, 0, true + case input.DocumentNextEntry: + return review.DocumentEntry, 0, false + case input.DocumentPreviousEntry: + return review.DocumentEntry, 0, true + case input.DocumentNextFormField: + return review.DocumentFormField, 0, false + case input.DocumentPreviousFormField: + return review.DocumentFormField, 0, true + case input.DocumentNextImage: + return review.DocumentImage, 0, false + case input.DocumentPreviousImage: + return review.DocumentImage, 0, true + case input.DocumentNextLandmark: + return review.DocumentLandmark, 0, false + case input.DocumentPreviousLandmark: + return review.DocumentLandmark, 0, true + case input.DocumentNextListItem: + return review.DocumentListItem, 0, false + case input.DocumentPreviousListItem: + return review.DocumentListItem, 0, true + case input.DocumentNextLiveRegion: + return review.DocumentLiveRegion, 0, false + case input.DocumentPreviousLiveRegion: + return review.DocumentLiveRegion, 0, true + case input.DocumentNextParagraph: + return review.DocumentParagraph, 0, false + case input.DocumentPreviousParagraph: + return review.DocumentParagraph, 0, true + case input.DocumentNextRadioButton: + return review.DocumentRadioButton, 0, false + case input.DocumentPreviousRadioButton: + return review.DocumentRadioButton, 0, true + case input.DocumentNextTable: + return review.DocumentTable, 0, false + case input.DocumentPreviousTable: + return review.DocumentTable, 0, true + case input.DocumentNextUnvisitedLink: + return review.DocumentUnvisitedLink, 0, false + case input.DocumentPreviousUnvisitedLink: + return review.DocumentUnvisitedLink, 0, true + case input.DocumentNextVisitedLink: + return review.DocumentVisitedLink, 0, false + case input.DocumentPreviousVisitedLink: + return review.DocumentVisitedLink, 0, true + case input.DocumentNextClickable: + return review.DocumentClickable, 0, false + case input.DocumentPreviousClickable: + return review.DocumentClickable, 0, true + case input.DocumentNextChunk: + return review.DocumentChunk, 0, false + case input.DocumentPreviousChunk: + return review.DocumentChunk, 0, true + } + return 0, 0, false +} + +func documentCaretCommand(command input.Command) review.DocumentCaretCommand { + switch command { + case input.DocumentTableLeft: + return review.TableLeft + case input.DocumentTableRight: + return review.TableRight + case input.DocumentTableUp: + return review.TableUp + case input.DocumentTableDown: + return review.TableDown + case input.DocumentTableFirst: + return review.TableFirst + case input.DocumentTableLast: + return review.TableLast + + case input.DocumentCaretNextCharacter: + return review.CaretNextCharacter + case input.DocumentCaretPreviousCharacter: + return review.CaretPreviousCharacter + case input.DocumentCaretNextWord: + return review.CaretNextWord + case input.DocumentCaretPreviousWord: + return review.CaretPreviousWord + case input.DocumentCaretNextLine: + return review.CaretNextLine + case input.DocumentCaretPreviousLine: + return review.CaretPreviousLine + case input.DocumentCaretLineStart: + return review.CaretLineStart + case input.DocumentCaretLineEnd: + return review.CaretLineEnd + case input.DocumentCaretDocumentStart: + return review.CaretDocumentStart + case input.DocumentCaretDocumentEnd: + return review.CaretDocumentEnd + } + return 0 +} + +func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { + if logger.Enabled(context.Background(), slog.LevelDebug) { + logger.Debug("document caret navigation completed", "elapsed", result.Elapsed, + "capture_duration", result.CaptureDuration, "capture_reason", result.CaptureReason, + "moved", result.Moved, "command", result.DocumentCaretCommand.String(), + "bus", result.DocumentPosition.Object.ID().BusName, + "source_path", result.DocumentPosition.Object.ID().Path, + "virtual_caret_offset", result.DocumentPosition.Offset, + "native_text_start", result.DocumentCaretStart, "native_text_end", result.DocumentCaretEnd, + "line_fragments", document_line_ranges(result.DocumentCaretLine), + "caret_sync_error", result.CaretErr, + "braille_line_available", result.DocumentBrailleLine != nil || result.DocumentCaretLine != nil, + "braille_read_error", result.DocumentBrailleErr) + } + if result.Err != nil { + logger.Warn("document caret navigation failed", "error", result.Err) + speech.Enqueue(speechRequest{Text: "Could not find current location.", Role: "system", Cause: "document-caret"}) + return + } + if !result.Moved && result.DocumentCaretText == "" { + return + } + text := result.DocumentCaretText + if text == " " { + text = "space" + } + if text == "\n" { + if !options.SpeakBlankLines { + return + } + text = "blank" + } + request := speechRequest{Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"} + request.Context = result.DocumentCaretContext + if len(request.Context) > 0 && request.Context[0].ID() == result.DocumentPosition.Snapshot.ID() { + request.Context = request.Context[1:] + } + if result.DocumentCaretCommand >= review.TableLeft && result.DocumentCaretCommand <= review.TableLast { + snapshot := result.DocumentPosition.Snapshot + prefix := result.DocumentTablePrefix + if result.Moved && options.SpeechVerbosityLevel != 0 && prefix != "" && + (result.DocumentTableRowHeader != "" || result.DocumentTableColumnHeader != "") { + parts := []string{ + presentation.TableHeaderText(result.DocumentTableRowHeader, "row header", options.SpeechVerbosityLevel), + presentation.TableHeaderText(result.DocumentTableColumnHeader, "column header", options.SpeechVerbosityLevel), + } + prefix = strings.TrimSpace(strings.Join(parts, " ")) + if strings.HasPrefix(request.Text, result.DocumentTablePrefix+" ") { + request.Text = prefix + strings.TrimPrefix(request.Text, result.DocumentTablePrefix) + } + } + if result.Moved && options.Reading != nil && options.Reading.OnlySpeakDisplayedText { + request.Text = snapshot.Name + } + vertical := result.DocumentCaretCommand != review.TableLeft && result.DocumentCaretCommand != review.TableRight + if result.Moved && vertical && snapshot.TableRowText != "" && presentation.FullRowEnabled(snapshot, options) { + if options.Reading != nil && options.Reading.OnlySpeakDisplayedText { + request.Text = snapshot.TableRowText + } else { + request.Text = strings.TrimSpace(strings.Join([]string{prefix, snapshot.TableRowText, result.DocumentTableSuffix}, " ")) + } + } + } + for _, ancestor := range result.DocumentCaretContext { + if ancestor.Role == "link" { + request.VoiceType = "hyperlink" + break + } + } + switch result.DocumentCaretCommand { + case review.CaretNextCharacter, review.CaretPreviousCharacter, review.CaretLineStart, review.CaretLineEnd: + request.Character = true + case review.CaretNextLine, review.CaretPreviousLine, review.CaretDocumentStart, review.CaretDocumentEnd, + review.CaretNextWord, review.CaretPreviousWord: + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: result.DocumentPosition.Snapshot, Text: text, RawText: result.DocumentCaretRawText}}} + if result.DocumentCaretCommand != review.CaretNextWord && result.DocumentCaretCommand != review.CaretPreviousWord { + request.Line = document_speech_line(line) + } + roles := map[string]bool{result.DocumentPosition.Snapshot.Role: true} + // Chromium commonly exposes link/heading text in static descendants. + // Python's web _generateRoleName includes those ancestor roles. Keep + // their identity separate from the leaf which owns the caret offset. + for _, ancestor := range result.DocumentCaretContext { + if (ancestor.Role != "link" && ancestor.Role != "heading") || roles[ancestor.Role] { + continue + } + roles[ancestor.Role] = true + ancestor.Name = "" + line.Contents = append(line.Contents, atspi.DocumentContent{Snapshot: ancestor}) + } + spoken := presentation.DocumentLineSpeechWithOptions(line, options) + request.Text, request.Chunks = spoken.Text, spoken.Chunks + request.Sound = presentation.DocumentLineSoundCues(line, options) + fallback := options + fallback.RoleSoundPresentation = "sound_and_speech" + fallbackSpeech := presentation.DocumentLineSpeechWithOptions(line, fallback) + request.FallbackText, request.FallbackChunks = fallbackSpeech.Text, fallbackSpeech.Chunks + } + speech.Interrupt() + if request.Text == "" && len(request.Sound) == 0 { + return + } + speech.Enqueue(request) +} + +func document_line_ranges(line *atspi.DocumentLine) []map[string]any { + if line == nil { + return nil + } + ranges := make([]map[string]any, 0, len(line.Contents)) + for _, content := range line.Contents { + ranges = append(ranges, map[string]any{"bus": content.Snapshot.BusName, + "source_path": content.Snapshot.Path, "native_text_start": content.Start, + "native_text_end": content.End, "bounds": content.Bounds}) + } + return ranges +} + +func documentResultMatchesCurrent(result review.Result, current atspi.Snapshot) bool { + if result.DocumentFocus == current.ID() { + return true + } + return result.Moved && result.Err == nil && + result.DocumentPosition.Snapshot.ID() == current.ID() +} + +func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { + if logger.Enabled(context.Background(), slog.LevelDebug) { + logger.Debug("document navigation completed", "elapsed", result.Elapsed, + "capture_duration", result.CaptureDuration, "capture_reason", result.CaptureReason, + "moved", result.Moved, "target", result.DocumentTarget, + "level", result.DocumentLevel, "backwards", result.DocumentBackwards, + "bus", result.DocumentPosition.Object.ID().BusName, + "source_path", result.DocumentPosition.Object.ID().Path, + "virtual_caret_offset", result.DocumentPosition.Offset, + "caret_sync_error", result.CaretErr, + "braille_line_available", result.DocumentBrailleLine != nil, + "braille_read_error", result.DocumentBrailleErr) + } + text, fallbackText, role := "", "", "system" + var spoken, fallbackSpeech presentation.Speech + var cues []sound.Cue + switch { + case errors.Is(result.Err, atspi.ErrNotInDocument): + text = "Not in web document content." + case result.Err != nil: + logger.Warn("document navigation failed", "error", result.Err) + text = "Could not find current location." + case !result.Moved: + switch result.DocumentTarget { + case review.DocumentHeading: + if result.DocumentLevel == 0 { + text = "No more headings." + } else { + text = fmt.Sprintf("No more headings at level %d.", result.DocumentLevel) + } + case review.DocumentLink: + text = "No more links." + case review.DocumentButton: + text = "No more buttons." + case review.DocumentCheckBox: + text = "No more check boxes." + case review.DocumentList: + text = "No more lists." + case review.DocumentSeparator: + text = "No more separators." + case review.DocumentBlockQuote: + text = "No more blockquotes." + case review.DocumentComboBox: + text = "No more combo boxes." + case review.DocumentEntry: + text = "No more entries." + case review.DocumentFormField: + text = "No more form fields." + case review.DocumentImage: + text = "No more images." + case review.DocumentLandmark: + text = "No landmark found." + case review.DocumentListItem: + text = "No more list items." + case review.DocumentLiveRegion: + text = "No more live regions." + case review.DocumentParagraph: + text = "No more paragraphs." + case review.DocumentRadioButton: + text = "No more radio buttons." + case review.DocumentTable: + text = "No more tables." + case review.DocumentUnvisitedLink: + text = "No more unvisited links." + case review.DocumentVisitedLink: + text = "No more visited links." + case review.DocumentClickable: + text = "No more clickables." + case review.DocumentChunk: + text = "No more large objects." + } + default: + if link := result.DocumentPosition.LinkedHeading; link != nil { + roleOnly := *link + roleOnly.Name, roleOnly.Text = "", "" + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: result.DocumentPosition.Snapshot}, {Snapshot: roleOnly}, + }} + spoken = presentation.DocumentLineSpeechWithOptions(line, options) + text = spoken.Text + cues = presentation.DocumentLineSoundCues(line, options) + fallbackOptions := options + fallbackOptions.RoleSoundPresentation = "sound_and_speech" + fallbackSpeech = presentation.DocumentLineSpeechWithOptions(line, fallbackOptions) + fallbackText = fallbackSpeech.Text + } else { + spoken = presentation.FocusSpeechWithOptions(result.DocumentPosition.Snapshot, options) + text = spoken.Text + cues = presentation.SoundCues(result.DocumentPosition.Snapshot, options) + if options.RoleSoundPresentation == "sound_only" { + fallbackOptions := options + fallbackOptions.RoleSoundPresentation = "sound_and_speech" + fallbackSpeech = presentation.FocusSpeechWithOptions(result.DocumentPosition.Snapshot, fallbackOptions) + fallbackText = fallbackSpeech.Text + } + } + role = result.DocumentPosition.Snapshot.Role + if result.DocumentWrapped { + if result.DocumentBackwards { + spoken = spoken.Append("Wrapping to bottom.", ". ") + text = spoken.Text + fallbackSpeech = fallbackSpeech.Append("Wrapping to bottom.", ". ") + fallbackText = fallbackSpeech.Text + } else { + spoken = spoken.Append("Wrapping to top.", ". ") + text = spoken.Text + fallbackSpeech = fallbackSpeech.Append("Wrapping to top.", ". ") + fallbackText = fallbackSpeech.Text + } + } + if result.CaretErr != nil { + logger.Warn("document caret synchronization failed", "error", result.CaretErr) + spoken = spoken.Append("Could not move the browser caret.", ". ") + text = spoken.Text + fallbackSpeech = fallbackSpeech.Append("Could not move the browser caret.", ". ") + fallbackText = fallbackSpeech.Text + } + } + speech.Interrupt() + voiceType := ancestor_voice(result.DocumentCaretContext) + if result.DocumentPosition.LinkedHeading != nil { + voiceType = "hyperlink" + } + ancestors := result.DocumentCaretContext + if len(ancestors) > 0 && ancestors[0].ID() == result.DocumentPosition.Snapshot.ID() { + ancestors = ancestors[1:] + } + speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: role, Sound: cues, Cause: "document-navigation", Context: ancestors, VoiceType: voiceType}) +} diff --git a/internal/app/document_review_test.go b/internal/app/document_review_test.go new file mode 100644 index 0000000..5b06e4d --- /dev/null +++ b/internal/app/document_review_test.go @@ -0,0 +1,424 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/sound" + "log/slog" + "testing" + "time" +) + +func TestDocumentPresentation(t *testing.T) { + heading := atspi.NewSnapshot("/h", "Reading section", "heading", "", "") + heading.HeadingLevel = 2 + link := atspi.NewSnapshot("/l", "Example", "link", "", "") + link.Visited = true + for _, tc := range []struct { + result review.Result + want string + }{ + {review.Result{Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: heading}}, "Reading section, heading level 2"}, + {review.Result{Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: link}}, "Example, visited link"}, + {review.Result{Moved: true, DocumentWrapped: true, DocumentPosition: review.DocumentPosition{Snapshot: heading}}, "Reading section, heading level 2. Wrapping to top."}, + {review.Result{Moved: true, DocumentBackwards: true, DocumentWrapped: true, DocumentPosition: review.DocumentPosition{Snapshot: heading}}, "Reading section, heading level 2. Wrapping to bottom."}, + {review.Result{DocumentTarget: review.DocumentHeading}, "No more headings."}, + {review.Result{DocumentTarget: review.DocumentLink}, "No more links."}, + {review.Result{DocumentTarget: review.DocumentButton}, "No more buttons."}, + {review.Result{DocumentTarget: review.DocumentCheckBox}, "No more check boxes."}, + {review.Result{DocumentTarget: review.DocumentList}, "No more lists."}, + {review.Result{DocumentTarget: review.DocumentSeparator}, "No more separators."}, + {review.Result{DocumentTarget: review.DocumentHeading, DocumentLevel: 3}, "No more headings at level 3."}, + {review.Result{DocumentTarget: review.DocumentBlockQuote}, "No more blockquotes."}, + {review.Result{DocumentTarget: review.DocumentComboBox}, "No more combo boxes."}, + {review.Result{DocumentTarget: review.DocumentEntry}, "No more entries."}, + {review.Result{DocumentTarget: review.DocumentFormField}, "No more form fields."}, + {review.Result{DocumentTarget: review.DocumentImage}, "No more images."}, + {review.Result{DocumentTarget: review.DocumentLandmark}, "No landmark found."}, + {review.Result{DocumentTarget: review.DocumentListItem}, "No more list items."}, + {review.Result{DocumentTarget: review.DocumentLiveRegion}, "No more live regions."}, + {review.Result{DocumentTarget: review.DocumentParagraph}, "No more paragraphs."}, + {review.Result{DocumentTarget: review.DocumentRadioButton}, "No more radio buttons."}, + {review.Result{DocumentTarget: review.DocumentTable}, "No more tables."}, + {review.Result{DocumentTarget: review.DocumentUnvisitedLink}, "No more unvisited links."}, + {review.Result{DocumentTarget: review.DocumentVisitedLink}, "No more visited links."}, + {review.Result{DocumentTarget: review.DocumentClickable}, "No more clickables."}, + {review.Result{DocumentTarget: review.DocumentChunk}, "No more large objects."}, + {review.Result{Err: atspi.ErrNotInDocument}, "Not in web document content."}, + {review.Result{Err: atspi.ErrStaleObject}, "Could not find current location."}, + } { + queue := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)} + presentDocumentResult(slog.New(slog.DiscardHandler), queue, tc.result, presentation.Options{}) + if got := <-queue.spoken; got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + select { + case <-queue.interrupt: + default: + t.Fatal("did not interrupt speech") + } + } +} + +func TestLinkedHeadingPresentation(t *testing.T) { + heading := atspi.NewSnapshot("/heading", "Linked heading", "heading", "", "") + heading.HeadingLevel = 2 + link := atspi.NewSnapshot("/link", "Linked heading", "link", "", "") + link.Visited = true + for _, mode := range []struct{ name, want string }{ + {"sound_and_speech", "Linked heading, heading level 2, visited link"}, + {"sound_only", "Linked heading, heading level 2"}, + {"speech_only", "Linked heading, heading level 2, visited link"}, + } { + t.Run(mode.name, func(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 2)} + presentDocumentResult(slog.New(slog.DiscardHandler), queue, review.Result{ + Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: heading, LinkedHeading: &link}, + }, presentation.Options{EnableSound: true, PlaySoundForRole: true, RoleSoundPresentation: mode.name}) + if got := <-speech.spoken; got != mode.want { + t.Fatalf("speech = %q, want %q", got, mode.want) + } + if mode.name != "speech_only" { + if cue := <-queue.sounds; cue.Name != "link_visited" { + t.Fatalf("cue = %+v", cue) + } + } + }) + } +} + +func TestDocumentPresentationUsesObjectSoundPolicy(t *testing.T) { + toggle := atspi.NewSnapshot("/toggle", "Power", "toggle button", "", "") + toggle.Checked = true + toggle.SoundGeometry = atspi.SoundGeometry{ + ObjectKnown: true, ReferenceKnown: true, + Object: atspi.Rectangle{X: 100, Y: 10, Width: 10, Height: 10}, + Reference: atspi.Rectangle{X: 100, Y: 0, Width: 1000, Height: 600}, + } + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 2)} + options := presentation.Options{ + EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, + RoleSoundPresentation: "sound_only", SpatializeObjectSounds: true, + } + presentDocumentResult(slog.New(slog.DiscardHandler), queue, review.Result{ + Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: toggle}, + }, options) + if got := <-speech.spoken; got != "Power" { + t.Fatalf("sound-only structural speech = %q", got) + } + for _, name := range []string{"button", "togglebutton_checked"} { + cue := <-queue.sounds + if cue.Name != name || cue.Pan != -1 { + t.Fatalf("structural cue = %#v, want %s panned left", cue, name) + } + } +} + +func TestDocumentReviewCommandMapping(t *testing.T) { + cases := []struct { + command input.Command + target review.DocumentTarget + level int + back bool + }{ + {input.DocumentNextHeadingAtLevel3, review.DocumentHeading, 3, false}, + {input.DocumentPreviousHeadingAtLevel3, review.DocumentHeading, 3, true}, + {input.DocumentNextComboBox, review.DocumentComboBox, 0, false}, + {input.DocumentPreviousComboBox, review.DocumentComboBox, 0, true}, + {input.DocumentNextChunk, review.DocumentChunk, 0, false}, + {input.DocumentPreviousClickable, review.DocumentClickable, 0, true}, + } + for _, tc := range cases { + target, level, back := documentReviewCommand(tc.command) + if target != tc.target || level != tc.level || back != tc.back { + t.Fatalf("%s: target=%v level=%d back=%v", tc.command, target, level, back) + } + } +} + +func TestDocumentNavigationReachesEventLoopSpeech(t *testing.T) { + id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) } + doc := atspi.NewSnapshot("/doc", "Page", "document web", "", "") + doc.BusName = ":1.42" + heading := atspi.NewSnapshot("/heading", "Section", "heading", "", "") + heading.BusName = ":1.42" + heading.HeadingLevel = 2 + tree, err := atspi.NewAccessibleTree(id("/doc"), []atspi.TreeNode{ + {Snapshot: doc, Children: []atspi.ObjectID{id("/heading")}}, {Snapshot: heading}, + }) + if err != nil { + t.Fatal(err) + } + adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree} + worker, err := review.NewWorker(adapter, time.Second) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := worker.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + defer worker.Stop(context.Background()) + defer cancel() + queue := &fakeSpeechQueue{spoken: make(chan string, 4), interrupt: make(chan struct{}, 4)} + done := make(chan error, 1) + go func() { + done <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0) + }() + expect := func(want string) { + t.Helper() + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("got %q want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("speech missing") + } + } + adapter.events <- atspi.NewEvent(atspi.FocusEvent, doc) + expect("Page, document web") + worker.MoveDocument(review.DocumentHeading, 0, false) + expect("Section, heading level 2") + worker.MoveDocument(review.DocumentHeading, 0, false) + expect("Section, heading level 2. Wrapping to top.") + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestDocumentNavigationDiscardsPendingOldCaret(t *testing.T) { + id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) } + doc := atspi.NewSnapshot("/doc", "Page", "document web", "", "") + doc.BusName = ":1.42" + link := atspi.NewSnapshot("/link", "Old link", "link", "", "") + link.BusName = ":1.42" + heading := atspi.NewSnapshot("/heading", "Next section", "heading", "", "") + heading.BusName = ":1.42" + tree, err := atspi.NewAccessibleTree(id("/doc"), []atspi.TreeNode{ + {Snapshot: doc, Children: []atspi.ObjectID{id("/link"), id("/heading")}}, + {Snapshot: link}, {Snapshot: heading}, + }) + if err != nil { + t.Fatal(err) + } + captureStarted := make(chan struct{}, 1) + releaseCapture := make(chan struct{}) + adapter := &fakeAdapter{events: make(chan atspi.Event)} + adapter.capture = func(ctx context.Context) (*atspi.AccessibleTree, error) { + captureStarted <- struct{}{} + select { + case <-releaseCapture: + return tree, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + worker, err := review.NewWorker(adapter, time.Second) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + done := make(chan error, 1) + go func() { + done <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 100*time.Millisecond) + }() + adapter.events <- atspi.NewEvent(atspi.FocusEvent, link) + if got := <-queue.spoken; got != "Old link, link" { + t.Fatalf("focus speech = %q", got) + } + if err := worker.MoveDocument(review.DocumentHeading, 0, false); err != nil { + t.Fatal(err) + } + select { + case <-captureStarted: + case <-time.After(time.Second): + t.Fatal("document capture did not start") + } + oldCaret := link + oldCaret.Text = "Old line" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldCaret) + // Receiving the unrelated event means the old caret reached its timer + // before the structural capture is allowed to finish. + unrelated := atspi.NewSnapshot("/unrelated", "", "static", "", "") + unrelated.BusName = ":1.99" + adapter.events <- atspi.NewEvent(atspi.DefunctStateEvent, unrelated) + close(releaseCapture) + select { + case got := <-queue.spoken: + if got != "Next section, heading" { + t.Fatalf("navigation speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("navigation speech missing") + } + select { + case got := <-queue.spoken: + t.Fatalf("old caret replaced navigation speech: %q", got) + case <-time.After(50 * time.Millisecond): + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestDocumentResultAcceptsSelfNavigationFocusTransition(t *testing.T) { + previous := atspi.NewSnapshot("/previous", "Previous", "heading", "", "") + target := atspi.NewSnapshot("/target", "Target", "heading", "", "") + moved := review.Result{ + Moved: true, + DocumentFocus: previous.ID(), + DocumentPosition: review.DocumentPosition{Snapshot: target}, + } + if !documentResultMatchesCurrent(moved, previous) { + t.Fatal("result for unchanged focus was rejected") + } + if !documentResultMatchesCurrent(moved, target) { + t.Fatal("result for navigation target was rejected") + } + other := atspi.NewSnapshot("/other", "Other", "heading", "", "") + if documentResultMatchesCurrent(moved, other) { + t.Fatal("result for unrelated focus was accepted") + } + boundary := moved + boundary.Moved = false + if documentResultMatchesCurrent(boundary, target) { + t.Fatal("unmoved result was accepted as a navigation transition") + } +} + +func TestCaretPresentationRetainsAncestorSemantics(t *testing.T) { + leaf := atspi.NewSnapshot("/text", "Entire longer label", "static", "", "") + link := atspi.NewSnapshot("/link", "Entire longer label", "link", "", "") + link.Visited = true + heading := atspi.NewSnapshot("/heading", "Entire heading", "heading", "", "") + heading.HeadingLevel = 3 + for _, tc := range []struct { + command review.DocumentCaretCommand + text, want string + }{ + {review.CaretNextLine, "Example", "Example, visited link, heading level 3"}, + {review.CaretPreviousWord, "Example", "Example, visited link, heading level 3"}, + {review.CaretNextCharacter, "E", "E"}, + {review.CaretLineStart, "E", "E"}, + } { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} + presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, review.Result{ + Moved: true, DocumentCaretCommand: tc.command, DocumentCaretText: tc.text, + DocumentPosition: review.DocumentPosition{Snapshot: leaf}, DocumentCaretContext: []atspi.Snapshot{leaf, link, heading}, + }, presentation.DefaultOptions()) + request := <-queue.requests + if request.Text != tc.want { + t.Fatalf("speech = %q, want %q", request.Text, tc.want) + } + wantCharacter := tc.command == review.CaretNextCharacter || tc.command == review.CaretLineStart + if request.Character != wantCharacter { + t.Fatalf("character voice = %v for command %v", request.Character, tc.command) + } + } +} + +func TestTableCaretHeadersFollowSpeechVerbosity(t *testing.T) { + cell := atspi.NewSnapshot("/cell", "42", "table cell", "", "") + cell.TableRowText = "42, 18" + result := review.Result{ + Moved: true, DocumentCaretCommand: review.TableRight, + DocumentCaretText: "Monday Total 42 Row 2, column 3.", + DocumentTablePrefix: "Monday Total", DocumentTableRowHeader: "Monday", DocumentTableColumnHeader: "Total", + DocumentPosition: review.DocumentPosition{Snapshot: cell}, + } + for _, tc := range []struct { + verbosity int + want string + }{{0, result.DocumentCaretText}, {1, "Monday row header Total column header 42 Row 2, column 3."}} { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} + options := presentation.DefaultOptions() + options.SpeechVerbosityLevel = tc.verbosity + presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, options) + if got := (<-queue.requests).Text; got != tc.want { + t.Fatalf("verbosity %d: %q, want %q", tc.verbosity, got, tc.want) + } + } + result.DocumentCaretCommand = review.TableDown + result.DocumentTableSuffix = "Row 2, column 3." + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} + options := presentation.DefaultOptions() + options.SpeechVerbosityLevel = 1 + reading := config.DefaultSettings().Reading + reading.ReadFullRowInDocumentTable = true + options.Reading = &reading + presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, options) + if got := (<-queue.requests).Text; got != "Monday row header Total column header 42, 18 Row 2, column 3." { + t.Fatalf("verbose full row = %q", got) + } + reading.OnlySpeakDisplayedText = true + queue = &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} + presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, options) + if got := (<-queue.requests).Text; got != "42, 18" { + t.Fatalf("displayed-only full row = %q", got) + } +} + +func TestCaretPresentationUsesAncestorRoleSound(t *testing.T) { + leaf := atspi.NewSnapshot("/text", "", "static", "", "") + link := atspi.NewSnapshot("/link", "Example", "link", "", "") + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 2)} + presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, review.Result{ + Moved: true, DocumentCaretCommand: review.CaretNextLine, DocumentCaretText: "Example", + DocumentPosition: review.DocumentPosition{Snapshot: leaf}, DocumentCaretContext: []atspi.Snapshot{leaf, link}, + }, presentation.Options{EnableSound: true, PlaySoundForRole: true, RoleSoundPresentation: "sound_only"}) + if got := <-speech.spoken; got != "Example" { + t.Fatalf("sound-only speech = %q", got) + } + select { + case cue := <-queue.sounds: + if cue.Name != "link" { + t.Fatalf("cue = %+v", cue) + } + default: + t.Fatal("missing link sound") + } +} diff --git a/internal/app/echo_preferences_test.go b/internal/app/echo_preferences_test.go new file mode 100644 index 0000000..c436548 --- /dev/null +++ b/internal/app/echo_preferences_test.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "path/filepath" + "reflect" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func TestEveryEchoPreferenceRoundTrips(t *testing.T) { + settings := config.DefaultSettings() + if !settings.EnableKeyEcho { + t.Fatal("key echo must default on") + } + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + values := preferences.FromSettings(settings) + // Flip each echo switch, including false defaults, to exercise both directions. + value := reflect.ValueOf(&values).Elem() + for i := 0; i < value.NumField(); i++ { + if value.Field(i).Kind() == reflect.Bool { + value.Field(i).SetBool(!value.Field(i).Bool()) + } + } + rate, pitch, volume := 100.0, 10.0, 10.0 + values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume, Family: &config.VoiceFamily{Name: "Test Voice", Lang: "en", Variant: "test"}} + values.EchoSpeechServer = "test-module" + next, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + store, err := config.NewStore(settings.Path) + if err != nil { + t.Fatal(err) + } + if _, err = store.Load(); err != nil { + t.Fatal(err) + } + if err = store.Save(next); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(settings.Path) + if err != nil { + t.Fatal(err) + } + if got := preferences.FromSettings(loaded); !reflect.DeepEqual(got, values) { + t.Fatalf("round trip = %+v; want %+v", got, values) + } +} + +func TestApplyUpdatesRunningTextAndKeyboardEcho(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableModifierKeys = false + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + live := &liveSettings{} + live.Store(settings) + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.live = live + queue := &fakeSpeechQueue{spoken: make(chan string, 10), interrupt: make(chan struct{}, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 10)} + listener := &fakeInputListener{events: make(chan input.Event, 10), modifierKeycodes: input.NewModifierKeycodes(37)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}, 2) + go func() { + processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live) + done <- struct{}{} + }() + go func() { + processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, inputEchoSettingsFromConfig(settings), live, nil) + done <- struct{}{} + }() + defer func() { cancel(); <-done; <-done }() + expect := func(want string) { + t.Helper() + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + snapshot := atspi.NewSnapshot("/editor", "", "text", "", "") + snapshot.Editable = true + insert := func(text string) { + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, text) + } + insert("a") + expect("a") + listener.events <- input.NewKeyEvent(67, true, "F1") + expect("f1") + <-queue.interrupt + values := preferences.FromSettings(settings) + values.EnableKeyEcho = false + // Echo-only changes must work without a speech settings updater. + if r := worker.apply(ctx, preferences.Request{Values: values, Save: true}); !r.Accepted || r.Close { + t.Fatalf("Apply = %+v", r) + } + insert("b") + insert("barrier") + expect("barrier") + listener.events <- input.NewKeyEvent(68, true, "F2") + listener.events <- input.NewKeyEvent(37, true, "Control_L") + for range 2 { + select { + case <-queue.interrupt: + case <-time.After(time.Second): + t.Fatal("keyboard barrier missing") + } + } + values.EnableKeyEcho = true + if r := worker.apply(ctx, preferences.Request{Values: values, Save: true, Close: true}); !r.Accepted || !r.Close { + t.Fatalf("OK = %+v", r) + } + insert("c") + expect("c") + listener.events <- input.NewKeyEvent(69, true, "F3") + expect("f3") + loaded, err := config.Load(settings.Path) + if err != nil || !loaded.EnableKeyEcho { + t.Fatalf("saved state: %+v, %v", loaded, err) + } +} + +func TestCustomEchoVoiceInheritanceAndRouting(t *testing.T) { + settings := config.DefaultSettings() + settings.UseCustomEchoVoice = true + rate, pitch, volume := 65.0, 10.0, 10.0 + settings.DefaultVoice = config.Voice{Rate: &rate, Family: &config.VoiceFamily{Name: "base", Lang: "en"}} + settings.EchoVoice = config.Voice{AveragePitch: &pitch, Gain: &volume} + settings.UseCustomEchoSpeechServer = true + settings.EchoSpeechServer = "echo-module" + got := echoSpeechSettings(settings) + if *got.Rate != 32 || *got.Pitch != 90 || *got.Volume != 100 || got.OutputModule != "echo-module" || got.SynthesisVoice != "base" { + t.Fatalf("echo settings = %+v", got) + } + if !customEcho(settings, "key") || !customEcho(settings, "character") || customEcho(settings, "word") || customEcho(settings, "sentence") || customEcho(settings, "") { + t.Fatal("wrong default custom voice routing") + } + settings.UseCustomEchoForWord = true + settings.UseCustomEchoForSentence = true + if !customEcho(settings, "word") || !customEcho(settings, "sentence") { + t.Fatal("custom routing flags ignored") + } + settings.EnableKeyEcho = false + if customEcho(settings, "key") { + t.Fatal("custom echo survived master disable") + } +} diff --git a/internal/app/echo_speaker.go b/internal/app/echo_speaker.go new file mode 100644 index 0000000..0cf8b7d --- /dev/null +++ b/internal/app/echo_speaker.go @@ -0,0 +1,250 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "reflect" + "sync" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +// echoSpeaker prepares all voices before publishing any. Speech and +// cancellation remain available while a preferences change is prepared. +type echoSpeaker struct { + socket string + initial config.Settings + mu sync.RWMutex + update sync.Mutex + voices map[string]*speech.Client + closed bool +} + +func newEchoSpeaker(socket string, settings config.Settings) *echoSpeaker { + return &echoSpeaker{socket: socket, initial: settings} +} +func (speaker *echoSpeaker) Connect(ctx context.Context) error { + return speaker.UpdateConfiguration(ctx, speaker.initial) +} +func (speaker *echoSpeaker) UpdateConfiguration(ctx context.Context, settings config.Settings) error { + if !speaker.update.TryLock() { + return errors.New("speech settings update already in progress") + } + defer speaker.update.Unlock() + candidates := make(map[string]*speech.Client) + closeClients := func(clients map[string]*speech.Client) error { + var result error + for _, client := range clients { + result = errors.Join(result, client.Close(context.Background())) + } + return result + } + voiceSettings := map[string]speech.Settings{"default": speechSettingsFromConfig(settings)} + for _, kind := range []string{"uppercase", "hyperlink", "system"} { + if kind == "hyperlink" && reflect.DeepEqual(settings.HyperlinkVoice, config.Voice{}) || kind == "system" && reflect.DeepEqual(settings.SystemVoice, config.Voice{}) { + continue + } + candidate := settings + candidate.DefaultVoice = config.EffectiveVoice(settings, kind) + voiceSettings[kind] = speechSettingsFromConfig(candidate) + } + if settings.UseCustomEchoVoice { + voiceSettings["echo"] = echoSpeechSettings(settings) + echoSettings := settings + echoSettings.DefaultVoice = config.EffectiveVoice(settings, "default") + merge_echo_voice(&echoSettings.DefaultVoice, settings.EchoVoice) + echoSettings.DefaultVoice = config.EffectiveVoice(echoSettings, "uppercase") + if settings.UseCustomEchoSpeechServer { + echoSettings.SpeechOutputModule = settings.EchoSpeechServer + } + // Echo endpoints allow 100/10/10; use the existing clamping mapper. + echoSettings.EchoVoice = echoSettings.DefaultVoice + voiceSettings["uppercase-echo"] = echoSpeechSettings(echoSettings) + } + for _, kind := range []string{"default", "echo", "uppercase", "uppercase-echo", "hyperlink", "system"} { + values, exists := voiceSettings[kind] + if !exists { + continue + } + client := speech.NewWithSettings(speaker.socket, values) + candidates[kind] = client + if err := client.Connect(ctx); err != nil { + closeClients(candidates) + return err + } + } + speaker.mu.Lock() + defer speaker.mu.Unlock() + if speaker.closed { + closeClients(candidates) + return errors.New("speech is closed") + } + if err := ctx.Err(); err != nil { + closeClients(candidates) + return err + } + for _, client := range speaker.voices { + if err := client.Cancel(ctx); err != nil { + closeClients(candidates) + return err + } + } + retired := speaker.voices + speaker.voices = candidates + _ = closeClients(retired) + return nil +} +func (speaker *echoSpeaker) client(kind string) *speech.Client { + if client := speaker.voices[kind]; client != nil { + return client + } + return speaker.voices["default"] +} +func (speaker *echoSpeaker) Speak(ctx context.Context, text string) error { + return speaker.SpeakVoice(ctx, text, "default") +} +func (speaker *echoSpeaker) SpeakVoice(ctx context.Context, text, kind string) error { + speaker.mu.RLock() + defer speaker.mu.RUnlock() + client := speaker.client(kind) + if client == nil { + return errors.New("speech is not connected") + } + return client.Speak(ctx, text) +} +func (speaker *echoSpeaker) SpeakCharacter(ctx context.Context, text string) error { + return speaker.speak_character(ctx, text, false) +} +func (speaker *echoSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { + return speaker.speak_character(ctx, text, true) +} +func (speaker *echoSpeaker) speak_character(ctx context.Context, text string, custom bool) error { + speaker.mu.RLock() + defer speaker.mu.RUnlock() + kind := "default" + if custom { + kind = "echo" + } + r, size := utf8.DecodeRuneInString(text) + if size == len(text) && unicode.IsUpper(r) { + kind = "uppercase" + if custom && speaker.voices["uppercase-echo"] != nil { + kind = "uppercase-echo" + } + } + client := speaker.client(kind) + if client == nil { + return errors.New("speech is not connected") + } + return client.SpeakCharacter(ctx, text) +} +func (speaker *echoSpeaker) SpeakEcho(ctx context.Context, text string) error { + return speaker.SpeakVoice(ctx, text, "echo") +} +func (speaker *echoSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { + return speaker.SpeakVoiceAndWait(ctx, text, "default", began) +} + +func (speaker *echoSpeaker) speak_uninterruptible(ctx context.Context, text string) error { + return speaker.SpeakVoiceAndWait(ctx, text, "system", nil) +} + +func (speaker *echoSpeaker) SpeakVoiceAndWait(ctx context.Context, text, kind string, began func()) error { + speaker.mu.RLock() + client := speaker.client(kind) + speaker.mu.RUnlock() + if client == nil { + return errors.New("speech is not connected") + } + return client.SpeakAndWait(ctx, text, began) +} +func (speaker *echoSpeaker) Cancel(ctx context.Context) error { + speaker.mu.RLock() + defer speaker.mu.RUnlock() + var result error + for _, client := range speaker.voices { + result = errors.Join(result, client.Cancel(ctx)) + } + return result +} +func (speaker *echoSpeaker) Close(ctx context.Context) error { + speaker.update.Lock() + defer speaker.update.Unlock() + speaker.mu.Lock() + defer speaker.mu.Unlock() + speaker.closed = true + var result error + for _, client := range speaker.voices { + result = errors.Join(result, client.Close(ctx)) + } + speaker.voices = nil + return result +} +func merge_echo_voice(voice *config.Voice, echo config.Voice) { + if echo.Rate != nil { + voice.Rate = echo.Rate + } + if echo.AveragePitch != nil { + voice.AveragePitch = echo.AveragePitch + } + if echo.Gain != nil { + voice.Gain = echo.Gain + } + if echo.Family != nil { + voice.Family = echo.Family + } +} +func echoSpeechSettings(settings config.Settings) speech.Settings { + voice := settings.DefaultVoice + if v := settings.EchoVoice.Rate; v != nil { + voice.Rate = v + } + if v := settings.EchoVoice.AveragePitch; v != nil { + voice.AveragePitch = v + } + if v := settings.EchoVoice.Gain; v != nil { + voice.Gain = v + } + if v := settings.EchoVoice.Family; v != nil { + voice.Family = v + } + // Cthulhu accepts UI endpoints 100/10/10 and clamps for SSIP. + for _, field := range []struct { + value **float64 + max float64 + }{{&voice.Rate, 99}, {&voice.AveragePitch, 9}, {&voice.Gain, 9}} { + if *field.value != nil { + value := min(**field.value, field.max) + *field.value = &value + } + } + settings.DefaultVoice = voice + result := speechSettingsFromConfig(settings) + if settings.UseCustomEchoSpeechServer { + result.OutputModule = settings.EchoSpeechServer + } + return result +} +func customEcho(settings config.Settings, kind string) bool { + if !settings.EnableKeyEcho || !settings.UseCustomEchoVoice { + return false + } + switch kind { + case "key": + return settings.UseCustomEchoForKey + case "character": + return settings.UseCustomEchoForCharacter + case "word": + return settings.UseCustomEchoForWord + case "sentence": + return settings.UseCustomEchoForSentence + } + return false +} diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go new file mode 100644 index 0000000..487d325 --- /dev/null +++ b/internal/app/echo_speaker_test.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "net" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/remote" +) + +func TestEchoSpeakerSwitchesBothVoicesTransactionally(t *testing.T) { + for _, wrapped := range []bool{false, true} { + t.Run(fmt.Sprintf("configured=%v", wrapped), func(t *testing.T) { test_echo_speaker(t, wrapped) }) + } +} + +func test_echo_speaker(t *testing.T, wrapped bool) { + path := filepath.Join(t.TempDir(), "speech.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + type utterance struct { + module, text string + rate, pitch string + } + spoken := make(chan utterance, 10) + var handlers sync.WaitGroup + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + for id := 1; ; id++ { + conn, err := listener.Accept() + if err != nil { + return + } + handlers.Add(1) + go func(id int) { + defer handlers.Done() + defer conn.Close() + reader := bufio.NewReader(conn) + module, rate, pitch := "", "", "" + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + command := strings.TrimSpace(line) + response := "200 OK\r\n" + if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") { + module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ") + } + if strings.HasPrefix(command, "SET SELF RATE ") { + rate = strings.TrimPrefix(command, "SET SELF RATE ") + } + if strings.HasPrefix(command, "SET SELF PITCH ") { + pitch = strings.TrimPrefix(command, "SET SELF PITCH ") + } + if command == "SET SELF OUTPUT_MODULE rejected" { + response = "400 Module unavailable\r\n" + } + if command == "HISTORY GET CLIENT_ID" { + response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id) + } + if strings.HasPrefix(command, "CHAR ") { + spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch} + } + if command == "SPEAK" { + var text strings.Builder + for { + part, err := reader.ReadString('\n') + if err != nil { + return + } + if part == ".\r\n" { + break + } + text.WriteString(strings.TrimSuffix(part, "\r\n")) + } + spoken <- utterance{module, text.String(), rate, pitch} + response = "230 OK\r\n200 QUEUED\r\n" + } + if _, err := conn.Write([]byte(response)); err != nil { + return + } + } + }(id) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + settings := config.DefaultSettings() + settings.UseCustomEchoVoice = true + settings.UseCustomEchoSpeechServer = true + settings.EchoSpeechServer = "echo-module" + rate := 80.0 + settings.EchoVoice.Rate = &rate + var speaker interface { + configuredVoice + UpdateConfiguration(context.Context, config.Settings) error + } = newEchoSpeaker(path, settings) + if wrapped { + speaker = newConfiguredSpeaker(path, settings) + } + defer func() { speaker.Close(ctx); listener.Close(); <-acceptDone; handlers.Wait() }() + if err := speaker.Connect(ctx); err != nil { + t.Fatal(err) + } + check := func(custom bool, wantModule, wantRate string) { + t.Helper() + var err error + if custom { + err = speaker.SpeakEcho(ctx, "test") + } else { + err = speaker.Speak(ctx, "test") + } + if err != nil { + t.Fatal(err) + } + select { + case got := <-spoken: + if got.module != wantModule || got.rate != wantRate { + t.Fatalf("utterance=%+v, want module %q rate %q", got, wantModule, wantRate) + } + case <-ctx.Done(): + t.Fatal("speech missing") + } + } + check(false, "", "") + check(true, "echo-module", "62") + checkCharacters := func(wantNormal string) { + t.Helper() + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + for _, tc := range []struct { + text string + character bool + pitch string + }{ + {"A", true, "50"}, {"a", true, wantNormal}, {"É", true, "50"}, + {"o", true, wantNormal}, {"o", true, wantNormal}, + {"猫", true, wantNormal}, {"7", true, wantNormal}, {"space", true, wantNormal}, + {"ABC", false, wantNormal}, {"A", false, wantNormal}, + } { + if err := worker.deliver(ctx, speechRequest{Text: tc.text, Character: tc.character}); err != nil { + t.Fatal(err) + } + select { + case got := <-spoken: + if got.pitch != tc.pitch || got.text != tc.text || got.module != "" { + t.Fatalf("character %v: got %+v, want text %q pitch %q", tc.character, got, tc.text, tc.pitch) + } + case <-ctx.Done(): + t.Fatal("character speech missing") + } + } + } + checkCharacters("") + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + // Exercise deletion from event presentation through the real SSIP client, + // checking both uppercase pitch and restoration on the following letter. + for _, custom := range []bool{false, true} { + deletionSettings := settings + deletionSettings.UseCustomEchoVoice = custom + live := &liveSettings{} + live.Store(deletionSettings) + queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} + for _, text := range []string{"A", "a", "É", "é"} { + snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true} + presenter.speak(atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, text)) + request := <-queue.requests + <-queue.spoken + if err := worker.deliver(ctx, request); err != nil { + t.Fatal(err) + } + got := <-spoken + wantPitch, wantModule, wantRate := "", "", "" + if text == "A" || text == "É" { + wantPitch = "50" + } + if custom { + wantModule, wantRate = "echo-module", "62" + } + if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate { + t.Fatalf("deleted %q, custom=%v: %+v", text, custom, got) + } + } + } + // Wine insertion must use the same character voice as native typing. + for _, custom := range []bool{false, true} { + wineSettings := settings + wineSettings.UseCustomEchoVoice = custom + live := &liveSettings{} + live.Store(wineSettings) + queue := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + presenter := remoteSpeech{speech: queue, braille: braille, live: live} + owner := newWineEventPresenter(func(message string) bool { + return presenter.presentMessage(message, "wine-accessible-event") + }) + presenter.wine = owner + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit"}) + owner.flush() + queue.requests, braille.text = nil, nil + for _, text := range []string{"A", "a", "É", "é", "é", "𐐀", "𐐨", "😀", "space", "new line"} { + if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{ + SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8", + }) { + t.Fatalf("Wine insertion %q rejected", text) + } + request := queue.requests[len(queue.requests)-1] + if err := worker.deliver(ctx, request); err != nil { + t.Fatal(err) + } + got := <-spoken + wantPitch, wantModule, wantRate := "", "", "" + if text == "A" || text == "É" || text == "𐐀" { + wantPitch = "50" + } + if custom { + wantModule, wantRate = "echo-module", "62" + } + if got.text != text || got.pitch != wantPitch || got.module != wantModule || got.rate != wantRate { + t.Fatalf("Wine insertion %q, custom=%v: %+v; want pitch %q module %q rate %q", text, custom, got, wantPitch, wantModule, wantRate) + } + if braille.text[len(braille.text)-1] != text { + t.Fatalf("Wine braille changed character case: %v", braille.text) + } + } + owner.Close() + } + for _, text := range []string{"A", "a", "É"} { + if err := worker.deliver(ctx, speechRequest{Text: text, Character: true, CustomEcho: true}); err != nil { + t.Fatal(err) + } + got := <-spoken + wantPitch := "50" + if text == "a" { + wantPitch = "" + } + if got.module != "echo-module" || got.rate != "62" || got.pitch != wantPitch { + t.Fatalf("custom character echo = %+v; want pitch %s", got, wantPitch) + } + } + upperPitch, linkPitch, systemPitch := 8.0, 1.0, 2.0 + settings.UppercaseVoice.AveragePitch = &upperPitch + settings.HyperlinkVoice.AveragePitch = &linkPitch + settings.SystemVoice.AveragePitch = &systemPitch + if err := speaker.UpdateConfiguration(ctx, settings); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + request speechRequest + pitch string + }{ + {speechRequest{Text: "A", Character: true}, "70"}, + {speechRequest{Text: "A", Character: true, CustomEcho: true}, "70"}, + {speechRequest{Text: "link text", Role: "link"}, "-70"}, + {speechRequest{Text: "status text", Role: "system"}, "-50"}, + {speechRequest{Text: "ordinary text"}, ""}, + {speechRequest{Spelling: []string{"alpha", "bravo"}, SpellingVoices: []string{"uppercase", ""}}, "70"}, + } { + if err := worker.deliver(ctx, tc.request); err != nil { + t.Fatal(err) + } + got := <-spoken + if got.pitch != tc.pitch { + t.Fatalf("voice selection for %+v = %+v", tc.request, got) + } + if len(tc.request.Spelling) != 0 { + if got := <-spoken; got.pitch != "" { + t.Fatalf("uppercase spelling pitch leaked: %+v", got) + } + } + } + // Verify real SSIP SPEAK requests from focus presentation, including source + // punctuation, the selected special voice, and disabling pause chunks. + for _, enabled := range []bool{true, false} { + pauseSettings := settings + pauseSettings.Reading.EnablePauseBreaks = enabled + pauseSettings.EnableSound = false + live := &liveSettings{} + live.Store(pauseSettings) + queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} + presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Read, draft. Next!", Role: "link", Description: "Open document"})) + if err := worker.deliver(ctx, <-queue.requests); err != nil { + t.Fatal(err) + } + want := []string{"Read, draft. Next!", "link", "Open document"} + if !enabled { + want = []string{"Read, draft. Next! link Open document"} + } + for _, text := range want { + select { + case got := <-spoken: + if got.text != text || got.pitch != "-70" || got.module != "" { + t.Fatalf("pause chunks enabled=%v: SSIP utterance=%+v, want text %q with hyperlink pitch", enabled, got, text) + } + case <-ctx.Done(): + t.Fatal("pause chunk SSIP request missing") + } + } + if len(spoken) != 0 { + t.Fatal("unexpected extra SSIP request") + } + } + settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice = config.Voice{}, config.Voice{}, config.Voice{} + if err := speaker.UpdateConfiguration(ctx, settings); err != nil { + t.Fatal(err) + } + settings.EchoSpeechServer = "rejected" + if err := speaker.UpdateConfiguration(ctx, settings); err == nil { + t.Fatal("invalid module accepted") + } + check(false, "", "") + check(true, "echo-module", "62") + settings.UseCustomEchoVoice = false + if err := speaker.UpdateConfiguration(ctx, settings); err != nil { + t.Fatal(err) + } + check(true, "", "") + pitch := 3.0 + settings.DefaultVoice.AveragePitch = &pitch + if err := speaker.UpdateConfiguration(ctx, settings); err != nil { + t.Fatal(err) + } + checkCharacters("-30") + if wrapped { + test_hardware_switch(t, speaker.(*configuredSpeaker), settings) + check(false, "", "") + checkCharacters("-30") + } + if err := speaker.Cancel(ctx); err != nil { + t.Fatal(err) + } + if err := speaker.Close(ctx); err != nil { + t.Fatal(err) + } + if err := speaker.UpdateConfiguration(ctx, settings); err == nil { + t.Fatal("closed speaker revived") + } +} diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go new file mode 100644 index 0000000..cc61efa --- /dev/null +++ b/internal/app/event_accessibility.go @@ -0,0 +1,285 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// accessibilityEventHandler handles native events on the event-loop goroutine. +// A return drops or consumes this event; the select loop then receives the next. +type accessibilityEventHandler struct { + state *eventLoopState + titles *windowTitleController + logger *slog.Logger + speech speechEnqueuer + adapter Adapter + objectReview *review.Worker + browser *browserModeController + inputSource any + focusGate *focusGate + notifications *notification.Presenter + live *liveSettings + brailleOutput brailleFrameEnqueuer + foreground foregroundMonitor + debounce time.Duration + clearOwnedFocus func() + syncForeground func() + flushTerminalDeletion func() + speak func(atspi.Event, ...input.Intent) + webScripts *webScriptController + wineReview *wineReviewFocusController +} + +func (handler *accessibilityEventHandler) handle(event atspi.Event) { + state := handler.state + logger := handler.logger + speech := handler.speech + adapter := handler.adapter + browser := handler.browser + inputSource := handler.inputSource + focusGate := handler.focusGate + notifications := handler.notifications + live := handler.live + brailleOutput := handler.brailleOutput + foreground := handler.foreground + debounce := handler.debounce + syncForeground := handler.syncForeground + flushTerminalDeletion := handler.flushTerminalDeletion + speak := handler.speak + webScripts := handler.webScripts + ownedAutocompletePopup := state.ownedAutocompletePopup + if focusGate.Locked() { + return + } + if overflow := adapter.Overflow(); overflow > state.lastOverflow { + logger.Warn("AT-SPI event queue overflow", "dropped_events", overflow-state.lastOverflow) + state.lastOverflow = overflow + } + if logger.Enabled(context.Background(), slog.LevelDebug) { + logger.Debug("received accessibility event", + "type", event.Type, + "native_type", event.NativeType, + "application", event.Source.ApplicationName, + "bus", event.Source.BusName, + "source_path", event.Source.Path, + "origin_path", event.OriginPath, + "window_bus", event.Window.BusName, + "window_path", event.Window.Path, + "window_recovered", event.WindowRecovered, + "focus_outside_document", event.FocusOutsideDocument.Path, + "role", event.Source.Role, + "browser_tab", event.Source.BrowserTab, + "selected", event.Source.Selected, + "radio_group_name", event.Source.RadioGroupName, + "prompt_message_present", event.Source.Text != "" && (event.Source.Role == "frame" || event.Source.Role == "alert") || event.WindowMessage != "", + "snapshot_duration", event.SnapshotDuration, + "editable", event.Source.Editable, + "autocomplete", event.Source.Autocomplete, + "autocomplete_popup", event.Source.AutocompletePopup, + "event_detail1", event.Detail1, + "native_caret_known", event.Source.TextCaretKnown, + "native_caret_offset", event.Source.BrailleCaret, + "native_line_start", event.Source.BrailleLineStart, + "character_count", event.Source.CharacterCount, + "selection_start", event.Source.SelectionStart, + "selection_end", event.Source.SelectionEnd) + } + syncForeground() + notification := eventBypassesXTermHandoff(event) + if handler.wineReview != nil && handler.wineReview.owns(state.current.ID()) && !notification { + return + } + ownedTooltip := event.Type == atspi.ShowingStateEvent && event.Source.Role == "tool tip" && + state.owner.applicationBus != "" && state.owner.applicationBus == event.Source.BusName && + event.Foreground.Epoch == state.foregroundState.Epoch + if webScripts.handle(event) { + return + } + if handler.handle_progress_bar(event) { + return + } + ending := event.Type == atspi.DefunctStateEvent || event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.WindowDeactivatedEvent + if foreground != nil && !notification && + (event.Foreground.Epoch != state.foregroundState.Epoch || + event.ForegroundMismatch && !ending && !ownedAutocompletePopup(event) && !ownedTooltip) { + if event.Type == atspi.WindowActivatedEvent { + check := event.ForegroundCheck + client := event.Foreground.Client + logger.Debug("ignored accessibility event outside X11 foreground", "type", event.Type, + "bus", event.Window.BusName, "window_path", event.Window.Path, + "x_window", client.Window, "epoch", event.Foreground.Epoch, + "current_epoch", state.foregroundState.Epoch, "reason", check.Reason, + "x_pid", client.PID, "accessible_pid", check.AccessiblePID, + "active", check.Active, "showing", check.Showing, "iconified", check.Iconified, + "accessible_bounds", check.Bounds, + "x_bounds", [4]int{client.X, client.Y, client.Width, client.Height}, + "x_outer_bounds", [4]int{client.OuterX, client.OuterY, client.OuterWidth, client.OuterHeight}) + } else { + logger.Debug("ignored accessibility event outside X11 foreground", "type", event.Type, + "bus", event.Window.BusName, "window_path", event.Window.Path, + "x_window", state.foregroundState.Client.Window, "epoch", event.Foreground.Epoch) + } + return + } + if handler.handleLifecycle(event) { + return + } + consumed := handler.acceptFocusOwnership(event, notification) + if isFocusGainEvent(event) && !notification && live != nil && live.onApplication != nil && + state.owner.applicationBus == event.Source.BusName && state.owner.window == eventWindow(event) { + application := handler.titles.applicationForEvent(event) + live.onApplication(application) + } + if event.Type == atspi.WindowActivatedEvent && state.owner.window == eventWindow(event) { + handler.titles.activated(event) + } + if consumed { + return + } + if notifications != nil { + message, speechMessages, handled, save := notificationEvent(event) + if save { + notifications.Save(message) + } + if handled { + presentNotifications(speech, speechMessages) + return + } + if save { + speak(event) + return + } + } + if handler.handleTooltip(event) { + return + } + activity := recentInput(inputSource) + if inputSource == nil && browser != nil { + activity = browser.RecentInput() + } + intent := activity.Intent + if isTextChangeEvent(event.Type) && event.Source.Editable && + event.Source.Role != "terminal" && !event.Source.LiveRegion && state.focusedID.Path != "" && + event.Source.ID() != state.focusedID && event.Source.ID() != state.managedDescendantID { + // Settings pages initialize editable number fields while focus + // remains in their navigation tree. These are not user edits. + logger.Debug("skipped text change outside focused editor", "type", event.Type, "role", event.Source.Role) + return + } + if event.Type == atspi.CaretEvent && !event.Source.Editable && !activity.Selecting && + state.focusedID.Path != "" && isFocusCaretControl(state.current.Role) && + (intent == input.NoIntent || intent == input.OtherIntent) && + presentation.EventText(event) == "\ufffc" { + // Gecko can move a containing panel's caret to an embedded + // control just after Tab. Preserve the control's focus, speech, + // and braille rather than adopting that object's placeholder. + logger.Debug("skipped embedded-object caret after control focus", "role", event.Source.Role) + return + } + if handler.handleBrowserTab(event, activity) { + return + } + if shouldPresentFileManagerStatus(event, state.current, state.owner, intent) { + status := strings.TrimSpace(event.Source.Name) + if state.lastFileManagerStatusWindow != state.owner.window || status != state.lastFileManagerStatus { + state.lastFileManagerStatusWindow, state.lastFileManagerStatus = state.owner.window, status + speech.Interrupt() + speech.Enqueue(speechRequest{Text: status, Role: "status bar", Cause: event.Type}) + if brailleOutput != nil { + brailleOptions := config.DefaultSettings().Braille + textAttributes := config.DefaultTextAttributes() + if live != nil { + settings := live.Load() + brailleOptions, textAttributes = settings.Braille, settings.TextAttributes + } + brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, brailleOptions, textAttributes)) + } + } + return + } + if handler.filterNavigationAndNames(event, activity, intent) { + return + } + event, autocompleteRequest, autocompleteConsumed := handler.handleAutocomplete(event, activity, intent) + if autocompleteConsumed { + return + } + if state.pending.terminalDeletion.event.Type != "" && event.Type != atspi.CaretEvent { + isReplacement := event.Type == atspi.TextInsertedEvent && + event.Source.Path != "" && + event.Source.Path == state.pending.terminalDeletion.event.Source.Path && + event.Detail1 == state.pending.terminalDeletion.event.Detail1 + if isReplacement { + state.pending.terminalDeletion.clear() + logger.Debug("coalesced terminal deletion with following insertion", "role", event.Source.Role) + } else { + flushTerminalDeletion() + } + } + if state.pending.terminalFocus.event.Type != "" { + if event.Type == atspi.TextInsertedEvent && + event.Source.Path != "" && + event.Source.Path == state.pending.terminalFocus.event.Source.Path { + state.pending.terminalFocus.clear() + logger.Debug("coalesced terminal focus with following insertion", "role", event.Source.Role) + } else if isFocusEvent(event.Type) { + state.pending.terminalFocus.clear() + } + } + if handler.acceptFocusedEvent(event, activity) { + return + } + if handler.handleSelectionAndClipboard(event, activity, intent) { + return + } + if handler.handleCaret(event, activity, intent) { + return + } + state.pending.caret.clear() + if isFocusEvent(event.Type) && event.Source.Role == "terminal" { + state.pending.terminalFocus.schedule(event, input.NoIntent, debounce) + return + } + if event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" { + state.pending.terminalDeletion.schedule(event, input.NoIntent, terminalMutationDebounce) + return + } + speak(event) + if autocompleteRequest.Text != "" { + speech.Enqueue(autocompleteRequest) + } +} + +// Thunar and PCManFM can leave AT-SPI focus on a file-view panel while arrowing +// through icons. The status-bar name is then the only changing accessible text +// for the selection. A normally focused file item keeps its own presentation. +func shouldPresentFileManagerStatus(event atspi.Event, current atspi.Snapshot, owner windowOwner, intent input.Intent) bool { + if event.Type != atspi.AccessibleNameEvent || event.Source.Role != "status bar" || + !strings.EqualFold(event.Source.ApplicationName, "thunar") && + !strings.EqualFold(event.Source.ApplicationName, "pcmanfm") || + strings.TrimSpace(event.Source.Name) == "" || + !owner.window.Valid() || event.Window != owner.window || + event.Source.BusName != owner.applicationBus { + return false + } + if intent != input.LineIntent && intent != input.CharacterIntent && intent != input.WordIntent { + return false + } + return current.Role == "panel" || current.Role == "directory pane" || + current.Role == "tree table" && current.Name == "" || + current.Role == "table cell" && current.Name == "" +} diff --git a/internal/app/event_autocomplete.go b/internal/app/event_autocomplete.go new file mode 100644 index 0000000..152c7ee --- /dev/null +++ b/internal/app/event_autocomplete.go @@ -0,0 +1,93 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// handleAutocomplete may rewrite insertion payload before normal presentation. +// A true consumed result discards popup and address-bar echo events in place. +func (handler *accessibilityEventHandler) handleAutocomplete(event atspi.Event, activity input.Activity, intent input.Intent) (atspi.Event, speechRequest, bool) { + state := handler.state + live := handler.live + logger := handler.logger + speak := handler.speak + var autocompleteRequest speechRequest + state.autocompleteAvailable.focus(event) + if event.Type == atspi.TextInsertedEvent && supportsAutocomplete(event.Source) { + if live != nil { + available := state.presentationOptions.SoundCueAvailable + state.presentationOptions = presentationSettingsFromConfig(live.Load(), true) + state.presentationOptions.SoundCueAvailable = available + } + if address := state.autocompleteAvailable.offered(event, activity); address != "" { + autocompleteRequest = speechRequest{Text: address, Role: "autocomplete", Cause: "autocomplete-available"} + if state.presentationOptions.EnableSound && state.presentationOptions.RoleSoundPresentation != "speech_only" && + (state.presentationOptions.SoundCueAvailable == nil || state.presentationOptions.SoundCueAvailable("autocomplete")) { + autocompleteRequest.Sound = []sound.Cue{{Name: "autocomplete"}} + } + } + } + if event.Source.AutocompletePopup { + explicitItem := isAutocompletePopupItem(event.Source.Role) && + ((isFocusGainEvent(event) && intent == input.LineIntent) || + (event.Type == "object:state-changed:selected" && event.Detail1 != 0 && intent == input.LineIntent)) + if explicitItem && event.Source.ID() != state.lastAutocompletePopupItem { + state.lastAutocompletePopupItem = event.Source.ID() + presentationEvent := event + presentationEvent.Type = atspi.FocusEvent + speak(presentationEvent) + } + logger.Debug("skipped autocomplete popup noise", "type", event.Type, "role", event.Source.Role) + return event, autocompleteRequest, true + } + if event.Source.Autocomplete { + if isFocusGainEvent(event) { + state.lastAutocompletePopupItem = atspi.ObjectID{} + } + if (isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent) && intent == input.LineIntent { + return event, autocompleteRequest, true // Up/Down presents the popup item, not the entry's replacement text. + } + if event.Type == atspi.TextDeletedEvent && intent == input.TypingIntent { + return event, autocompleteRequest, true // Replacing the selected URL or completion is not a user deletion. + } + if event.Type == atspi.TextInsertedEvent && intent == input.TypingIntent { + event.Payload = state.autocompleteTyping.insertion(event, activity) + if autocompleteRequest.Text != "" { + event.Payload = "" // The selected address replaces this insertion's character echo. + } + } + } + return event, autocompleteRequest, false +} + +func isBrowserUINameNoise(event atspi.Event, focus atspi.Snapshot) bool { + if event.Type != atspi.AccessibleNameEvent { + return false + } + // Browser chrome labels, status bars, and frames can change independently + // of focus. Current controls and rows still need their new names presented. + // Orca's web UI noise filter likewise limits these ignored roles. + switch strings.ToLower(event.Source.ToolkitName) { + case "chromium", "gecko", "webkit", "webkitgtk": + return event.Source.Role == "status bar" || event.Source.Role == "label" || + event.Source.Role == "frame" + } + if event.Source.AutocompletePopup { + return true + } + return focus.Autocomplete && event.Source.ID().BusName != "" && + event.Source.ID().BusName == focus.ID().BusName +} + +func isAutocompletePopupItem(role string) bool { + return role == "list item" || role == "menu item" +} diff --git a/internal/app/event_browser_tabs.go b/internal/app/event_browser_tabs.go new file mode 100644 index 0000000..7b91061 --- /dev/null +++ b/internal/app/event_browser_tabs.go @@ -0,0 +1,74 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +// handleBrowserTab presents a selected tab while leaving native focus in chrome. +func (handler *accessibilityEventHandler) handleBrowserTab(event atspi.Event, activity input.Activity) bool { + state := handler.state + browser := handler.browser + objectReview := handler.objectReview + speech := handler.speech + brailleOutput := handler.brailleOutput + live := handler.live + if event.Source.BrowserTab && (event.Type == atspi.SelectedStateEvent || event.Type == atspi.SelectionChangedEvent) { + // Python's web page-switch handler presents the tab without moving + // the locus of focus. Repeated Ctrl+T can keep the same address + // control focused and produce no further focus events at all. + selected := event.Type == atspi.SelectedStateEvent && event.Detail1 != 0 || + event.Type == atspi.SelectionChangedEvent && event.Source.Selected && + strings.EqualFold(event.Source.ToolkitName, "Gecko") + if !selected || event.Source.StatesKnown && !event.Source.Selected || + event.Source.Defunct || !state.owner.containsLoad(event) || event.Source.ID() == state.selectedBrowserTab { + return true + } + state.selectedBrowserTab = event.Source.ID() + state.tabFocusWindow, state.tabFocusSequence = eventWindow(event), activity.Sequence + state.pending.caret.clear() + state.documentLineFocus, state.documentLineRequest, state.documentLineEvent = atspi.ObjectID{}, 0, atspi.Event{} + state.loadingDocument, state.loadedDocument = atspi.ObjectID{}, atspi.ObjectID{} + state.chromeLoadedDocument, state.chromeLoadedWindow = atspi.ObjectID{}, atspi.ObjectID{} + if browser != nil { + browser.clear() + } + if objectReview != nil { + focus := atspi.ObjectID{} + if state.current.Autocomplete { + focus = state.current.ID() + } + objectReview.ResetDocument(focus) + } + spoken := presentation.FocusSpeechWithOptions(event.Source, state.presentationOptions) + text := spoken.Text + if text != "" { + speech.Enqueue(speechRequest{Text: text, Chunks: spoken.Chunks, Role: event.Source.Role, Cause: event.Type}) + } + if brailleOutput != nil { + brailleOptions := config.DefaultSettings().Braille + textAttributes := config.DefaultTextAttributes() + if live != nil { + settings := live.Load() + brailleOptions, textAttributes = settings.Braille, settings.TextAttributes + } + brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, brailleOptions, textAttributes)) + } + return true + } + if event.Source.Autocomplete && eventWindow(event) == state.tabFocusWindow && + activity.Sequence != 0 && activity.Sequence == state.tabFocusSequence && + (event.Type == atspi.CaretEvent || event.Type == "object:state-changed:expanded") { + return true // Automatic address reset must not cut off the selected tab. + } + return false +} diff --git a/internal/app/event_caret.go b/internal/app/event_caret.go new file mode 100644 index 0000000..f7b9e7d --- /dev/null +++ b/internal/app/event_caret.go @@ -0,0 +1,98 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activity input.Activity, intent input.Intent) bool { + state := handler.state + logger := handler.logger + speech := handler.speech + browser := handler.browser + objectReview := handler.objectReview + debounce := handler.debounce + if event.Type == atspi.CaretEvent { + if event.Source.ID() == state.selectionCaretID { + state.selectionCaretID = atspi.ObjectID{} + if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond { + logger.Debug("skipped caret covered by text selection") + return true + } + } + if (event.Source.Autocomplete || activity.Sequence != 0 && + (event.Source.Role == "spin button" || event.Source.Role == "button")) && + event.Source.ID() == state.focusCaretID && activity.Sequence == state.focusCaretSequence && + !activity.Selecting && + (intent == input.NoIntent || intent == input.OtherIntent) { + // Address bars, spin buttons, and Gecko buttons move their + // text caret on focus. With the same input sequence and no + // caret-navigation intent, this must not interrupt the label. + logger.Debug("skipped control caret caused by focus", "role", event.Source.Role) + return true + } + if browser != nil && browser.ready && !event.Source.Editable { + state.current = event.Source + if objectReview != nil { + objectReview.SyncFocus(event.Source.ID()) + if err := objectReview.SyncDocumentCaret(event.Source.ID(), event.Detail1); err != nil { + logger.Debug("could not queue native document caret", "error", err) + } + } + } + if browser != nil && browser.ready && !event.Source.Editable && event.Source.TextCaretKnown && event.Source.Role != "password text" { + if state.documentSelection.id != event.Source.ID() { + state.documentSelection.reset(atspi.Snapshot{BusName: event.Source.BusName, Path: event.Source.Path}) + } + fragmentLimit := selectionFragmentLimit + if activity.Selecting && intent == input.LineIntent { + fragmentLimit = lineSelectionFragmentLimit + } + if message := state.documentSelection.changedWithLimit(event.Source, fragmentLimit); message != "" { + state.pending.caret.clear() + speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: event.Type}) + return true + } + } + + // Any newer caret event supersedes an outstanding visual-line + // assembly, even when the new event can use its already-captured + // current-object line. + state.documentLineFocus = atspi.ObjectID{} + state.documentLineRequest = 0 + state.documentLineEvent = atspi.Event{} + // Python's default non-layout web mode reads the current object's + // line. A blank browse caret is not a reason to scan the whole + // document; focus mode still assembles visual lines across objects. + if browser != nil && browser.ready && browser.mode == review.FocusMode && + intent == input.LineIntent && !event.Source.Editable && objectReview != nil { + state.current = event.Source + objectReview.SyncFocus(event.Source.ID()) + if request, err := objectReview.ReadDocumentLine(event.Source.ID(), event.Detail1); err == nil { + state.documentLineFocus = event.Source.ID() + state.documentLineRequest = request + state.documentLineEvent = event + logger.Debug("queued document caret line", "role", event.Source.Role) + return true + } + } + if event.Source.Path != "" && event.Source.Path == state.suppressNextCaretPath { + state.suppressNextCaretPath = "" + logger.Debug("skipped caret event already covered by text change", "type", event.Type, "role", event.Source.Role) + return true + } + // Keep a short coalescing window for selection/focus events without + // adding the terminal focus delay to every caret movement. + state.pending.caret.schedule(event, intent, min(debounce, 20*time.Millisecond)) + return true + } + return false +} diff --git a/internal/app/event_caret_test.go b/internal/app/event_caret_test.go new file mode 100644 index 0000000..4a9c35e --- /dev/null +++ b/internal/app/event_caret_test.go @@ -0,0 +1,226 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "First line")) + select { + case text := <-speech.spoken: + if text != "First line" { + t.Fatalf("spoken text = %q, want %q", text, "First line") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for first caret speech") + } + + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "First line")) + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Second line")) + select { + case text := <-speech.spoken: + if text != "Second line" { + t.Fatalf("spoken text = %q, want %q", text, "Second line") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for second caret speech") + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestProcessEventsSuppressesCaretAfterEditableTextChange(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + paragraph := atspi.Snapshot{Path: "/writer/paragraph", Role: "paragraph", Editable: true, + Text: "Hello", CaretCharacter: "o"} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, paragraph) + <-speech.spoken + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "o") + if got := <-speech.spoken; got != "o" { + t.Fatalf("insert speech = %q", got) + } + adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, + atspi.Snapshot{Path: "/status", Role: "label"}, "status update") + + select { + case got := <-speech.spoken: + t.Fatalf("unexpected duplicate or status speech: %q", got) + case <-time.After(30 * time.Millisecond): + } + + cancel() + <-completed +} + +func TestProcessEventsSpeaksCaretCharacters(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt ls", "l", + )) + select { + case text := <-speech.spoken: + if text != "l" { + t.Fatalf("spoken text = %q, want %q", text, "l") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for first caret character speech") + } + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt ll", "l", + )) + select { + case text := <-speech.spoken: + if text != "l" { + t.Fatalf("repeated caret character = %q, want l", text) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for repeated caret character speech") + } + + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt ls", "s", + )) + select { + case text := <-speech.spoken: + if text != "s" { + t.Fatalf("spoken text = %q, want %q", text, "s") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for second caret character speech") + } + + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt ls", " ", + )) + select { + case text := <-speech.spoken: + if text != "space" { + t.Fatalf("spoken caret space = %q, want %q", text, "space") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for caret space speech") + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestProcessEventsTextChangeCancelsPendingCaretLine(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, time.Hour) + close(completed) + }() + + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt l") + adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "l") + + select { + case text := <-speech.spoken: + if text != "l" { + t.Fatalf("spoken text = %q, want %q", text, "l") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for inserted text speech") + } + + select { + case text := <-speech.spoken: + t.Fatalf("unexpected additional speech: %q", text) + default: + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestCaretEventRequestsCharacterVoice(t *testing.T) { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { defer close(done); processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }() + defer func() { cancel(); <-done }() + adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter("/terminal", "", "terminal", "", "Ab", "A")) + select { + case request := <-queue.requests: + if request.Text != "A" || !request.Character { + t.Fatalf("caret request = %+v", request) + } + case <-time.After(time.Second): + t.Fatal("caret speech missing") + } +} diff --git a/internal/app/event_filters.go b/internal/app/event_filters.go new file mode 100644 index 0000000..b2bc569 --- /dev/null +++ b/internal/app/event_filters.go @@ -0,0 +1,73 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func isFocusEvent(eventType string) bool { + return eventType == atspi.FocusEvent || eventType == atspi.FocusedStateEvent || eventType == atspi.WindowActivatedEvent +} + +func isMenuRole(role string) bool { + return role == "menu" || role == "menu item" || role == "check menu item" || role == "radio menu item" +} + +func isFocusGainEvent(event atspi.Event) bool { + return event.Type == atspi.FocusEvent || + event.Type == atspi.WindowActivatedEvent || + (event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) +} + +func isStateChangeEvent(eventType string) bool { + return strings.HasPrefix(eventType, "object:state-changed:") && eventType != atspi.FocusedStateEvent +} + +func isValueChangeEvent(eventType string) bool { + return eventType == atspi.ValueChangedEvent || eventType == atspi.AccessibleValueEvent +} + +func isManagedDescendantEvent(eventType string) bool { + return eventType == atspi.ActiveDescendantEvent || eventType == atspi.SelectionChangedEvent +} + +func sameSelectionItem(source, current atspi.Snapshot) bool { + return (source.Role == "tree item" || source.Role == "list item") && + source.ID() == current.ID() && source.Role == current.Role && source.Name == current.Name && + source.Selected == current.Selected && source.Expandable == current.Expandable && source.Expanded == current.Expanded +} + +func sameManagedDescendant(source, current atspi.Snapshot) bool { + return source.ID() == current.ID() && source.Role == current.Role && + source.Name == current.Name && source.TableRowText == current.TableRowText && + source.Selected == current.Selected && source.Expanded == current.Expanded && + source.Checked == current.Checked +} + +func presentsReusedRow(source atspi.Snapshot) bool { + return source.Role == "table row" || source.Role == "table cell" && source.GUITable +} + +func isFocusCaretControl(role string) bool { + switch role { + case "button", "toggle button", "check box", "radio button", "combo box", "spin button", "tree item", "list item": + return true + default: + return false + } +} + +func speaksTextChanges(snapshot atspi.Snapshot) bool { + return snapshot.Role == "terminal" || + (snapshot.Editable && !snapshot.ReadOnly && snapshot.Role != "password text") +} + +func isTextChangeEvent(eventType string) bool { + return eventType == atspi.TextInsertedEvent || eventType == atspi.TextDeletedEvent +} diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go new file mode 100644 index 0000000..98f1a1e --- /dev/null +++ b/internal/app/event_focus.go @@ -0,0 +1,152 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "slices" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, activity input.Activity) bool { + state := handler.state + logger := handler.logger + objectReview := handler.objectReview + browser := handler.browser + brailleOutput := handler.brailleOutput + live := handler.live + // Compare with the owned focus snapshot, not the last speech request: + // a state/value update can intervene between two toolkit focus events. + // Still refresh review and braille below before suppressing presentation. + duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) && + event.Source.Role != "terminal" && !presentsReusedRow(event.Source) && + !(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) && + state.focusedID.Valid() && event.Source.ID() == state.focusedID && + event.Source.ID() == state.current.ID() && + presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) && + slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions)) + if isFocusEvent(event.Type) { + focusID := event.Source.ID() + if isFocusGainEvent(event) { + state.focusCaretID = focusID + state.focusCaretSequence = activity.Sequence + } else { + state.focusCaretID = atspi.ObjectID{} + } + if focusID != state.managedContainerID && focusID != state.managedDescendantID { + state.managedContainerID = atspi.ObjectID{} + state.managedDescendantID = atspi.ObjectID{} + } + // GTK table focus is normalized to its selected cell by the native + // adapter. Retain the captured table owner before the first arrow + // move, even when no container focus event reached this loop. + if event.Source.Role == "table cell" && event.Source.GUITable { + tableID := atspi.NewObjectID(event.Source.BusName, event.Source.TablePath) + if tableID.Valid() { + state.managedContainerID = tableID + state.managedDescendantID = focusID + } + } + state.focusedID = focusID + state.selection.reset(event.Source) + if objectReview != nil { + objectReview.SyncFocus(event.Source.ID()) + } + } else if isManagedDescendantEvent(event.Type) { + containerID := atspi.NewObjectID(event.Source.BusName, event.OriginPath) + descendantID := event.Source.ID() + if event.Source.Defunct || event.Source.Role == "invalid" || descendantID.Path == "" { + logger.Debug("skipped invalid managed descendant", "type", event.Type) + return true + } + if state.focusedID.Path == "" || + (containerID != state.focusedID && descendantID != state.focusedID && containerID != state.managedContainerID) { + logger.Debug("skipped managed descendant outside focused object", "type", event.Type, "role", event.Source.Role) + return true + } + if containerID == state.managedContainerID && descendantID == state.managedDescendantID && + (!presentsReusedRow(event.Source) || sameManagedDescendant(event.Source, state.current)) { + logger.Debug("skipped unchanged managed descendant", "type", event.Type, "role", event.Source.Role) + return true + } + state.managedContainerID = containerID + state.managedDescendantID = descendantID + if event.Type == atspi.SelectionChangedEvent && sameSelectionItem(event.Source, state.current) { + logger.Debug("skipped selection already presented by focus", "role", event.Source.Role) + return true + } + } else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) && + (state.focusedID.Path == "" || event.Source.ID() != state.focusedID) { + logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role) + return true + } + if event.Type == atspi.SelectedStateEvent && sameSelectionItem(event.Source, state.current) { + logger.Debug("skipped selected state already presented by focus", "role", event.Source.Role) + return true + } + if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) || + event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) && + event.Source.ID() == state.managedDescendantID || + (event.Source.Path != "" && event.Source.ID() == state.focusedID) { + state.current = event.Source + } + if isTextChangeEvent(event.Type) { + if !speaksTextChanges(event.Source) && !event.Source.LiveRegion { + logger.Debug("skipped text change outside editable content", "type", event.Type, "role", event.Source.Role) + return true + } + if !event.Source.LiveRegion { + state.suppressNextCaretPath = event.Source.Path + } + } + if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable { + state.current = event.Source + } + if brailleOutput != nil && (isFocusGainEvent(event) || + isManagedDescendantEvent(event.Type) || event.Type == atspi.CaretEvent || + event.Type == atspi.AccessibleNameEvent && + (event.Source.ID() == state.focusedID || presentsReusedRow(event.Source)) || + event.Type == atspi.TextSelectionChangedEvent && event.Source.TextCaretKnown || + isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type) || + isTextChangeEvent(event.Type) && speaksTextChanges(event.Source)) && + event.Source.ID() == state.current.ID() { + source := event.Source + if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) && + !source.TextCaretKnown && source.ID() == state.brailleSnapshot.ID() { + source.TextCaretKnown = state.brailleSnapshot.TextCaretKnown + source.BrailleLine = state.brailleSnapshot.BrailleLine + source.BrailleLineStart = state.brailleSnapshot.BrailleLineStart + source.BrailleCaret = state.brailleSnapshot.BrailleCaret + } + if len(source.BrailleContext) == 0 && source.ID() == state.brailleSnapshot.ID() { + source.BrailleContext = state.brailleSnapshot.BrailleContext + } + state.brailleSnapshot = source + brailleOptions := config.DefaultSettings().Braille + textAttributes := config.DefaultTextAttributes() + if live != nil { + settings := live.Load() + brailleOptions, textAttributes = settings.Braille, settings.TextAttributes + } + frame := brailleFocusFrameWithOptions(source, brailleOptions, textAttributes) + if browser != nil && browser.ready && browser.mode == review.BrowseMode && !source.Editable && + objectReview != nil && browser.document.Valid() && source.Role != "password text" { + focus := objectReview.Focus() + frame.Document = &braille.DocumentRoute{Epoch: objectReview.DocumentEpoch(), FocusBus: focus.BusName, FocusPath: focus.Path, + DocumentBus: browser.document.BusName, DocumentPath: browser.document.Path} + } + brailleOutput.EnqueueFrame(frame) + } + if duplicateFocus { + logger.Debug("skipped unchanged accessible focus", "type", event.Type, "role", event.Source.Role) + } + return duplicateFocus +} diff --git a/internal/app/event_focus_ownership.go b/internal/app/event_focus_ownership.go new file mode 100644 index 0000000..1598f30 --- /dev/null +++ b/internal/app/event_focus_ownership.go @@ -0,0 +1,130 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/webscripts" +) + +// acceptFocusOwnership keeps native window focus, XTerm handoff, and navigation +// echoes ahead of input intent and normal presentation. +func (handler *accessibilityEventHandler) acceptFocusOwnership(event atspi.Event, notification bool) bool { + state := handler.state + logger := handler.logger + speech := handler.speech + focusGate := handler.focusGate + objectReview := handler.objectReview + brailleOutput := handler.brailleOutput + ownedAutocompletePopup := state.ownedAutocompletePopup + if isFocusGainEvent(event) { + if event.Type == atspi.WindowActivatedEvent { + focusBeforeActivation := state.owner.window.Valid() && state.owner.window == eventWindow(event) && + state.owner.focus.Valid() && !state.owner.activated + // Window ownership and object focus are independent. Providers can + // repeat activation for the same top-level around an X11 key grab, + // and the activation snapshot can be the frame while the focused + // object is one of its descendants. Treating that source mismatch as + // a new activation interrupts review speech and resets its cursor. + if state.owner.activate(event) { + logger.Debug("skipped duplicate accessible window activation", "application", event.Source.ApplicationName, "bus", state.owner.window.BusName, "window_path", state.owner.window.Path, "focus_path", event.Source.Path) + return true + } + state.lastFileManagerStatus, state.lastFileManagerStatusWindow = "", atspi.ObjectID{} + logger.Debug("activated accessible window", "application", event.Source.ApplicationName, "bus", state.owner.window.BusName, "window_path", state.owner.window.Path, "focus_path", event.Source.Path) + if (event.Source.Role == "alert" || event.Source.Role == "frame") && + state.pendingPromptTextAnnounced && state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow { + // The prompt's window:create already queued its title and + // message. Activation must not cut that announcement short. + return true + } + if focusBeforeActivation { + text := strings.TrimSpace(event.WindowTitle) + if (event.Source.Role == "button" || event.Source.Role == "tree table" || event.Source.Role == "table cell") && event.WindowMessage != "" { + if text != "" { + text += ", " + } + text += event.WindowMessage + } + if text != "" { + speech.Interrupt() + speech.Enqueue(speechRequest{Text: text, Role: "frame", Cause: event.Type, + Window: eventWindow(event), IntroducesWindow: true, + ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow}) + } + return true + } + } else if !ownedAutocompletePopup(event) && !state.owner.follow(event) { + logger.Debug("skipped focus outside active window", "type", event.Type, "application", event.Source.ApplicationName, "bus", event.Window.BusName, "window_path", event.Window.Path, "focus_path", event.Source.Path) + return true + } + if isXTermFocus(event) { + state.brailleSnapshot = atspi.Snapshot{} + if brailleOutput != nil { + brailleOutput.EnqueueFrame(braille.Frame{Cursor: -1}) + } + state.pending.clear() + focusGate.SetSuspended(true) + state.focusedID = event.Source.ID() + state.current = event.Source + if objectReview != nil { + objectReview.SyncFocus(event.Source.ID()) + } + logger.Info("suspended for XTerm focus handoff") + return true + } + if !notification { + focusGate.SetSuspended(false) + } + } + if focusGate.Suspended() && !notification { + logger.Debug("skipped event during XTerm focus handoff", "type", event.Type, "role", event.Source.Role) + return true + } + if event.NavigationEcho { + return true + } + // The native adapter already filters focused-state losses. Keep the + // event loop defensive for alternate adapters and tests: Python does + // not present or follow the object which just lost focus. + if event.Type == atspi.FocusedStateEvent && event.Detail1 == 0 { + return true + } + return false +} + +func isXTermSnapshot(snapshot atspi.Snapshot) bool { + return strings.EqualFold(snapshot.ApplicationName, "xterm") +} + +func isXTermFocus(event atspi.Event) bool { + if isXTermSnapshot(event.Source) { + return true + } + return event.Source.Role == "terminal" && event.Source.ApplicationName == "" && xorg.ActiveWindowIsXTerm() +} + +func eventBypassesXTermHandoff(event atspi.Event) bool { + if webscripts.HandlesNotification(event.Source.ApplicationProcess, event.Type, event.Detail1, event.WebNotification) { + return true + } + role := strings.ToLower(event.Source.Role) + if role == "notification" || role == "alert" { + return true + } + switch event.Type { + case "window:create", "object:property-change:accessible-name", atspi.AccessibleValueEvent: + switch strings.ToLower(event.Source.ApplicationName) { + case "notification-daemon", "notify-osd", "xfce4-notifyd", "mate-notification-daemon": + return true + } + } + return false +} diff --git a/internal/app/event_focus_test.go b/internal/app/event_focus_test.go new file mode 100644 index 0000000..1d17443 --- /dev/null +++ b/internal/app/event_focus_test.go @@ -0,0 +1,414 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestProcessEventsSuppressesDuplicateFocusObjects(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", "")) + saveFocused := atspi.NewEvent("object:state-changed:focused", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", "")) + saveFocused.Detail1 = 1 + adapter.events <- saveFocused + cancelFocused := atspi.NewEvent("object:state-changed:focused", atspi.NewSnapshot("/accessible/cancel", "Cancel", "push button", "", "")) + cancelFocused.Detail1 = 1 + adapter.events <- cancelFocused + + for _, expected := range []string{"Save, push button", "Cancel, push button"} { + select { + case text := <-speech.spoken: + if text != expected { + t.Fatalf("spoken text = %q, want %q", text, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for speech %q", expected) + } + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + go func() { + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, + fixedInputActivity{input.Activity{Intent: input.LineIntent}}, nil, nil) + close(completed) + }() + defer func() { cancel(); <-completed }() + + frame := atspi.NewSnapshot("/thunar/window", "Thunar", "frame", "", "") + frame.BusName, frame.ApplicationName = ":1.45", "thunar" + activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) + activated.Window = frame.ID() + adapter.events <- activated + <-speech.spoken + + file := atspi.NewSnapshot("/thunar/file", "Audio", "table cell", "", "") + file.BusName, file.ApplicationName = frame.BusName, "thunar" + focused := atspi.NewEvent(atspi.FocusedStateEvent, file) + focused.Detail1, focused.Window = 1, frame.ID() + adapter.events <- focused + if got := <-speech.spoken; got != "Audio" { + t.Fatalf("file speech = %q", got) + } + + for _, role := range []string{"directory pane", "status bar"} { + changed := atspi.NewSnapshot("/thunar/"+role, "Updated "+role, role, "", "") + changed.BusName, changed.ApplicationName = frame.BusName, "thunar" + event := atspi.NewEvent(atspi.AccessibleNameEvent, changed) + event.Window = frame.ID() + adapter.events <- event + } + select { + case got := <-speech.spoken: + t.Fatalf("unfocused name change interrupted file: %q", got) + case <-time.After(30 * time.Millisecond): + } + + status := atspi.NewSnapshot("/thunar/status bar", "Selected file", "status bar", "", "") + status.BusName, status.ApplicationName = frame.BusName, "thunar" + statusFocus := atspi.NewEvent(atspi.FocusedStateEvent, status) + statusFocus.Detail1, statusFocus.Window = 1, frame.ID() + adapter.events <- statusFocus + if got := <-speech.spoken; got != "Selected file, status bar" { + t.Fatalf("focused status bar speech = %q", got) + } + status.Name = "Updated status" + statusChanged := atspi.NewEvent(atspi.AccessibleNameEvent, status) + statusChanged.Window = frame.ID() + adapter.events <- statusChanged + if got := <-speech.spoken; got != "Updated status, status bar" { + t.Fatalf("focused status bar update = %q", got) + } +} + +func TestProcessEventsFiltersUnfocusedStateAndValueChanges(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + focused := atspi.Snapshot{Path: "/volume", Name: "Volume", Role: "slider", ValueKnown: true, + CurrentValue: 20, MinimumValue: 0, MaximumValue: 100} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, focused) + if got := <-speech.spoken; got != "Volume, slider, 20" { + t.Fatalf("focus speech = %q", got) + } + + unfocused := atspi.Snapshot{Path: "/other", Name: "Other", Role: "slider", ValueKnown: true, + CurrentValue: 90, MinimumValue: 0, MaximumValue: 100} + adapter.events <- atspi.NewEvent(atspi.ValueChangedEvent, unfocused) + adapter.events <- atspi.NewEvent("object:state-changed:sensitive", atspi.Snapshot{ + Path: "/other", Name: "Other", Role: "push button", SensitiveKnown: true, + }) + focused.CurrentValue = 30 + adapter.events <- atspi.NewEvent(atspi.ValueChangedEvent, focused) + + select { + case got := <-speech.spoken: + if got != "30" { + t.Fatalf("value-change speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for focused value change") + } + select { + case got := <-speech.spoken: + t.Fatalf("unexpected unfocused event speech: %q", got) + default: + } + + cancel() + <-completed +} + +func TestProcessEventsPresentsFocusedManagedDescendant(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + list := atspi.NewSnapshot("/run-dialog/results", "Run dialog suggestions", "list", "", "") + adapter.events <- atspi.NewEvent(atspi.FocusEvent, list) + if got := <-speech.spoken; got != "Run dialog suggestions, list" { + t.Fatalf("list focus speech = %q", got) + } + + result := atspi.NewSnapshot("/run-dialog/results/1", "LibreOffice Writer", "table cell", "", "") + adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, result) + if got := <-speech.spoken; got != "LibreOffice Writer" { + t.Fatalf("selected result speech = %q", got) + } + + // GTK can emit both selection and active-descendant events for the same row. + adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, result) + select { + case got := <-speech.spoken: + t.Fatalf("duplicate managed-descendant speech = %q", got) + case <-time.After(30 * time.Millisecond): + } + // A feed refresh can briefly expose defunct descendants and repeatedly + // select the same row while updating its relative timestamp. None of + // these events represents navigation or a new post. + invalid := atspi.NewSnapshot("/run-dialog/results/defunct", "", "invalid", "", "") + adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, invalid) + adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, result) + updated := result + updated.Name = "LibreOffice Writer, 2 minutes ago" + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, updated) + + // GTK can move explicit focus to the current child before reporting a + // sibling as the container's new selected descendant. The focused child + // remains owned by the same managed container, so the sibling change is + // still relevant. + focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, result) + focusEvent.Detail1 = 1 + adapter.events <- focusEvent + sibling := atspi.NewSnapshot("/run-dialog/results/2", "Calculator", "table cell", "", "") + adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, sibling) + if got := <-speech.spoken; got != "Calculator" { + t.Fatalf("sibling result speech = %q", got) + } + + other := atspi.NewSnapshot("/other/results/1", "Unrelated", "table cell", "", "") + adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/other/results", other) + select { + case got := <-speech.spoken: + t.Fatalf("unfocused selection speech = %q", got) + case <-time.After(30 * time.Millisecond): + } + + cancel() + <-completed +} + +func TestGeckoCurrentButtonNameArrivesAfterFocus(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3)} + frames := make(chan braille.Frame, 3) + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: speech, braille: frameRecorder{frames}}, adapter, 0) + close(done) + }() + defer func() { cancel(); <-done }() + + button := atspi.NewSnapshot("/mail/toolbar/button", "", "button", "", "") + button.ToolkitName = "Gecko" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, button) + if got := <-speech.spoken; got != "button" { + t.Fatalf("initial unnamed button = %q", got) + } + <-frames + button.Name = "Open calendar" + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, button) + select { + case got := <-speech.spoken: + if got != "Open calendar, button" { + t.Fatalf("updated button name = %q", got) + } + case <-time.After(time.Second): + t.Fatal("updated Gecko button name was not spoken") + } + select { + case frame := <-frames: + if frame.Text != "Open calendar button" { + t.Fatalf("updated button braille = %q", frame.Text) + } + case <-time.After(time.Second): + t.Fatal("updated Gecko button name did not refresh braille") + } +} + +func TestReusedTableRowSelectionReadsChangedMessage(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(done) + }() + defer func() { cancel(); <-done }() + + row := atspi.NewSnapshot("/mail/list/row", "First message", "table row", "", "") + row.ToolkitName = "Gecko" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, row) + if got := <-speech.spoken; got != "First message, table row" { + t.Fatalf("first message = %q", got) + } + selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/mail/list", row) + adapter.events <- selected + + replacement := row + replacement.Name = "Next message" + adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/mail/list", replacement) + select { + case got := <-speech.spoken: + if got != "Next message, table row" { + t.Fatalf("replacement message = %q", got) + } + case <-time.After(time.Second): + t.Fatal("replacement table row was not spoken") + } + + third := replacement + third.Name = "Third message" + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, third) + select { + case got := <-speech.spoken: + if got != "Third message, table row" { + t.Fatalf("renamed message = %q", got) + } + case <-time.After(time.Second): + t.Fatal("renamed table row was not spoken") + } +} + +func TestFileManagerStatusFallbackRequiresOwnedUnnamedNavigationFocus(t *testing.T) { + window := atspi.NewObjectID(":1.45", "/thunar/window") + owner := windowOwner{applicationBus: window.BusName, window: window} + status := atspi.NewSnapshot("/thunar/status", `"Beta" | 0 bytes`, "status bar", "", "") + status.BusName = window.BusName + status.ApplicationName = "thunar" + event := atspi.NewEvent(atspi.AccessibleNameEvent, status) + event.Window = window + panel := atspi.NewSnapshot("/thunar/file-view", "", "panel", "", "") + if !shouldPresentFileManagerStatus(event, panel, owner, input.LineIntent) { + t.Fatal("selected-file status was lost while focus remained on the file-view panel") + } + event.Source.ApplicationName = "pcmanfm" + if !shouldPresentFileManagerStatus(event, panel, owner, input.LineIntent) { + t.Fatal("PCManFM icon selection status was lost while focus remained on its panel") + } + event.Source.ApplicationName = "thunar" + for _, test := range []struct { + name string + modify func(*atspi.Event, *atspi.Snapshot, *windowOwner) + intent input.Intent + }{ + {"no navigation key", nil, input.NoIntent}, + {"named file focus", func(_ *atspi.Event, focus *atspi.Snapshot, _ *windowOwner) { + focus.Role, focus.Name = "table cell", "Beta" + }, input.LineIntent}, + {"other window", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { + event.Window = atspi.NewObjectID(":1.45", "/other/window") + }, input.LineIntent}, + {"other application", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { event.Source.ApplicationName = "editor" }, input.LineIntent}, + {"empty status", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { event.Source.Name = "" }, input.LineIntent}, + } { + t.Run(test.name, func(t *testing.T) { + candidate, focus, active := event, panel, owner + if test.modify != nil { + test.modify(&candidate, &focus, &active) + } + if shouldPresentFileManagerStatus(candidate, focus, active, test.intent) { + t.Fatal("unrelated status change took over file navigation") + } + }) + } +} + +func TestProcessEventsSpeaksFileManagerSelectionStatusAfterPanelFocus(t *testing.T) { + for _, applicationName := range []string{"thunar", "pcmanfm"} { + t.Run(applicationName, func(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + go func() { + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, + fixedInputActivity{input.Activity{Intent: input.LineIntent}}, nil, nil) + close(completed) + }() + defer func() { cancel(); <-completed }() + + frame := atspi.NewSnapshot("/thunar/window", "Thunar", "frame", "", "") + frame.BusName, frame.ApplicationName = ":1.45", applicationName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) + activated.Window = frame.ID() + adapter.events <- activated + if got := <-speech.spoken; got != "Thunar, frame" { + t.Fatalf("window speech = %q", got) + } + panel := atspi.NewSnapshot("/thunar/file-view", "", "panel", "", "") + panel.BusName, panel.ApplicationName = frame.BusName, applicationName + focused := atspi.NewEvent(atspi.FocusedStateEvent, panel) + focused.Detail1, focused.Window = 1, frame.ID() + adapter.events <- focused + if got := <-speech.spoken; got != "panel" { + t.Fatalf("panel speech = %q", got) + } + status := atspi.NewSnapshot("/thunar/status", `"Beta" | 0 bytes`, "status bar", "", "") + status.BusName, status.ApplicationName = frame.BusName, applicationName + changed := atspi.NewEvent(atspi.AccessibleNameEvent, status) + changed.Window = frame.ID() + adapter.events <- changed + if got := <-speech.spoken; got != status.Name { + t.Fatalf("selected file speech = %q, want %q", got, status.Name) + } + adapter.events <- changed + select { + case got := <-speech.spoken: + t.Fatalf("duplicate status speech = %q", got) + case <-time.After(30 * time.Millisecond): + } + }) + } +} diff --git a/internal/app/event_gui_table_test.go b/internal/app/event_gui_table_test.go new file mode 100644 index 0000000..83c8cd5 --- /dev/null +++ b/internal/app/event_gui_table_test.go @@ -0,0 +1,233 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestTableWindowDescriptionSurvivesImmediateTableFocus(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.20", "/desktop") + cell := atspi.NewSnapshot("/desktop/table/item", "Alpha", "table cell", "", "") + cell.BusName = window.BusName + cell.TableName = "Desktop items" + cell.TableRowCount, cell.TableColumnCount = 2, 2 + cell.TableRowText, cell.GUITable = "Alpha, Document", true + activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell) + activated.Window = window + activated.WindowTitle = "Desktop" + activated.WindowDescription = "Select an item and press Enter to open it." + adapter.events <- activated + table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "") + table.BusName = window.BusName + table.TableRowCount, table.TableColumnCount = 2, 2 + focused := atspi.NewEvent(atspi.FocusedStateEvent, table) + focused.Window = window + focused.Detail1 = 1 + adapter.events <- focused + for index, expected := range []struct { + text string + append bool + }{ + {"Desktop, Desktop items, table with 2 rows 2 columns, Alpha, Document, Select an item and press Enter to open it.", false}, + {"Desktop items, table with 2 rows 2 columns", true}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + <-completed +} + +func TestTableDescriptionArrivingAfterActivation(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.21", "/desktop") + table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "") + table.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, table) + activated.Window = window + activated.WindowTitle = "Desktop" + adapter.events <- activated + first := <-speech.requests + if first.Text != "Desktop, Desktop items, table" { + t.Fatalf("activation = %q", first.Text) + } + table.TableRowCount, table.TableColumnCount = 2, 2 + focused := atspi.NewEvent(atspi.FocusedStateEvent, table) + focused.Window = window + focused.WindowDescription = "Select an item and press Enter to open it." + focused.Detail1 = 1 + adapter.events <- focused + second := <-speech.requests + if second.Text != "Desktop items, table with 2 rows 2 columns, Select an item and press Enter to open it." { + t.Fatalf("focused table = %q", second.Text) + } + cell := atspi.NewSnapshot("/desktop/table/first", "Alpha", "table cell", "", "") + cell.BusName = window.BusName + cell.TablePath = table.Path + cell.TableRowText, cell.GUITable = "Alpha, Document", true + cell.TablePositionKnown = true + third := atspi.NewEvent(atspi.FocusedStateEvent, cell) + third.Window = window + third.Detail1 = 1 + adapter.events <- third + request := <-speech.requests + if request.Text != "Alpha, Document" || !request.Append { + t.Fatalf("following cell = %q append %t", request.Text, request.Append) + } + cancel() + <-completed +} + +func TestGUITableHorizontalMovementReadsOneCell(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + inputs := &sequencedInputActivity{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, + nil, nil, 0, nil, inputs, nil, nil) + close(completed) + }() + window := atspi.NewObjectID(":1.22", "/desktop") + table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "") + table.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, table) + activated.Window = window + adapter.events <- activated + <-speech.requests + cellEvent := func(path, name, rowText string, row, column int, eventType string) atspi.Event { + cell := atspi.NewSnapshot(path, name, "table cell", "", "") + cell.BusName = window.BusName + cell.TablePath = table.Path + cell.TableRowIndex, cell.TableColumnIndex = row, column + cell.TablePositionKnown, cell.GUITable = true, true + cell.TableRowText = rowText + event := atspi.NewManagedDescendantEvent(eventType, table.Path, cell) + event.Window = window + return event + } + for _, step := range []struct { + sequence uint64 + event atspi.Event + want string + }{ + {1, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.SelectionChangedEvent), "Alpha, Document"}, + {2, cellEvent("/desktop/table/document", "Document", "Alpha, Document", 0, 1, atspi.ActiveDescendantEvent), "Document"}, + {3, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.ActiveDescendantEvent), "Alpha"}, + {4, cellEvent("/desktop/table/beta", "Beta", "Beta, Shortcut", 1, 0, atspi.SelectionChangedEvent), "Beta, Shortcut"}, + {4, cellEvent("/desktop/table/replacement", "Renamed", "Renamed, Shortcut", 1, 0, atspi.ActiveDescendantEvent), "Renamed"}, + } { + inputs.set(step.sequence) + adapter.events <- step.event + select { + case request := <-speech.requests: + if request.Text != step.want { + t.Fatalf("sequence %d = %q, want %q", step.sequence, request.Text, step.want) + } + case <-time.After(time.Second): + t.Fatalf("sequence %d produced no speech", step.sequence) + } + } + cancel() + <-completed +} + +func TestGUITableReusedCurrentCellReadsReplacementRow(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + defer func() { cancel(); <-completed }() + + cell := atspi.NewSnapshot("/mail/messages/row", "First subject", "table cell", "", "") + cell.BusName = ":1.23" + cell.TablePath = "/mail/messages" + cell.TableRowIndex, cell.TableColumnIndex = 0, 0 + cell.TablePositionKnown, cell.GUITable = true, true + cell.TableRowText = "First subject, First sender" + focus := atspi.NewEvent(atspi.FocusedStateEvent, cell) + focus.Detail1 = 1 + adapter.events <- focus + if got := (<-speech.requests).Text; got != cell.TableRowText { + t.Fatalf("initial row = %q", got) + } + + replacement := cell + replacement.Name = "Next subject" + replacement.TableRowText = "Next subject, Next sender" + selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, cell.TablePath, replacement) + adapter.events <- selected + select { + case request := <-speech.requests: + if request.Text != replacement.TableRowText { + t.Fatalf("replacement row = %q, want %q", request.Text, replacement.TableRowText) + } + case <-time.After(time.Second): + t.Fatal("replacement row was not spoken") + } + + // A name event is another way a provider can expose a reused row. + third := replacement + third.Name = "Third subject" + third.TableRowText = "Third subject, Third sender" + nameChanged := atspi.NewEvent(atspi.AccessibleNameEvent, third) + adapter.events <- nameChanged + select { + case request := <-speech.requests: + if request.Text != third.TableRowText { + t.Fatalf("renamed row = %q, want %q", request.Text, third.TableRowText) + } + case <-time.After(time.Second): + t.Fatal("renamed current row was not spoken") + } +} diff --git a/internal/app/event_lifecycle.go b/internal/app/event_lifecycle.go new file mode 100644 index 0000000..7bb5c62 --- /dev/null +++ b/internal/app/event_lifecycle.go @@ -0,0 +1,161 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// handleLifecycle returns true when window or document lifecycle consumes an event. +func (handler *accessibilityEventHandler) handleLifecycle(event atspi.Event) bool { + state := handler.state + logger := handler.logger + speech := handler.speech + browser := handler.browser + objectReview := handler.objectReview + focusGate := handler.focusGate + clearOwnedFocus := handler.clearOwnedFocus + if event.Type == atspi.WindowActivatedEvent && strings.TrimSpace(event.WindowTitle) == speechHistoryWindowTitle { + state.historyWindow = eventWindow(event) + } + if event.Type == atspi.WindowDeactivatedEvent || event.Type == atspi.WindowDestroyedEvent { + recentCommand := browser != nil && browser.recentConsumedCommand() + if transientForegroundDeactivation(event, state.foregroundState) { + // Native correlation, not a time heuristic, confirms that + // this is still the actual foreground client. + recentCommand = true + } + if state.owner.end(event, recentCommand) { + clearOwnedFocus() + logger.Debug("cleared accessible window ownership", "type", event.Type, "application", event.Source.ApplicationName, "bus", event.Window.BusName, "window_path", event.Window.Path) + } else { + logger.Debug("ignored unrelated or transient window lifecycle", "type", event.Type, "application", event.Source.ApplicationName, "bus", event.Window.BusName, "window_path", event.Window.Path) + } + return true + } + if event.Type == atspi.DefunctStateEvent { + windowEnded, focusEnded := state.owner.defunct(event) + if focusEnded { + clearOwnedFocus() + logger.Debug("cleared defunct accessibility ownership", "window_ended", windowEnded, "application", event.Source.ApplicationName, "bus", event.Source.BusName, "source_path", event.Source.Path) + } + return true + } + if browser != nil && !focusGate.Suspended() && + (event.Type == atspi.DocumentReloadEvent || event.Type == atspi.DocumentLoadStoppedEvent || event.Type == atspi.BusyStateEvent) { + if !state.owner.containsLoad(event) { + logger.Debug("ignored document lifecycle outside owned focus", "type", event.Type, + "bus", event.Source.BusName, "window_path", event.Window.Path) + return true + } + if event.FocusOutsideDocument.Valid() { + logger.Debug("kept browser chrome focus during document lifecycle", "type", event.Type) + return true + } + if event.Type == atspi.BusyStateEvent && event.Detail1 == 0 && + state.chromeLoadedDocument == event.Source.ID() && state.chromeLoadedWindow == eventWindow(event) && + state.owner.focus.Valid() && state.owner.focus != event.Source.ID() && + !(browser.ready && browser.document == event.Source.ID()) { + logger.Debug("kept verified chrome focus during document completion") + return true + } + if event.Type == atspi.DocumentReloadEvent || event.Type == atspi.BusyStateEvent && event.Detail1 != 0 { + if state.loadingDocument != event.Source.ID() { + state.loadingDocument = event.Source.ID() + state.loadedDocument = atspi.ObjectID{} + if event.Type == atspi.BusyStateEvent { + speech.Enqueue(speechRequest{Text: "Loading. Please wait.", Role: "system", Cause: "document-loading"}) + } + } + browser.loading() + if objectReview != nil { + objectReview.ResetDocument(event.Source.ID()) + } + } else { + state.pending.caret.clear() + state.documentLineFocus = atspi.ObjectID{} + state.documentLineRequest = 0 + state.documentLineEvent = atspi.Event{} + if state.loadedDocument != event.Source.ID() { + state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" + } + state.owner.follow(event) + state.current = event.Source + state.focusedID = event.Source.ID() + if event.Type == atspi.BusyStateEvent && state.loadedDocument != event.Source.ID() { + speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true}) + state.loadedDocument = event.Source.ID() + } + state.loadingDocument = atspi.ObjectID{} + browser.loaded(event.Source.ID()) + if objectReview != nil { + objectReview.ResetDocument(event.Source.ID()) + } + } + return true + } + if event.Type == atspi.DocumentLoadCompleteEvent && browser != nil && !focusGate.Suspended() { + if !state.owner.containsLoad(event) { + logger.Debug("ignored document load outside owned focus", "bus", event.Source.BusName, "window_path", event.Window.Path) + return true + } + alreadyAnnounced := state.loadedDocument == event.Source.ID() && state.loadingDocument != event.Source.ID() + state.pending.caret.clear() + state.documentLineFocus = atspi.ObjectID{} + state.documentLineRequest = 0 + state.documentLineEvent = atspi.Event{} + if !alreadyAnnounced { + state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" + } + if event.FocusOutsideDocument.Valid() { + state.chromeLoadedDocument = event.Source.ID() + state.chromeLoadedWindow = eventWindow(event) + browser.loaded(event.FocusOutsideDocument) + if objectReview != nil { + objectReview.ResetDocument(event.FocusOutsideDocument) + } + logger.Debug("kept browser chrome focus during document load") + return true + } + state.chromeLoadedDocument = atspi.ObjectID{} + state.chromeLoadedWindow = atspi.ObjectID{} + state.owner.follow(event) + state.current = event.Source + state.focusedID = event.Source.ID() + browser.loaded(event.Source.ID()) + if objectReview != nil { + objectReview.ResetDocument(event.Source.ID()) + if event.DocumentFragment != "" { + if err := objectReview.JumpToDocumentFragment(event.DocumentFragment); err != nil { + logger.Warn("queue document fragment navigation", "error", err) + } + } + } + if !alreadyAnnounced { + speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true}) + } + state.loadingDocument = atspi.ObjectID{} + state.loadedDocument = event.Source.ID() + return true + } + return false +} + +// Keyboard grabs can make GTK emit deactivate/activate while X11 still owns +// the same foreground client. Native correlation is stronger than a timer. +func transientForegroundDeactivation(event atspi.Event, foreground atspi.ForegroundState) bool { + return event.Type == atspi.WindowDeactivatedEvent && !event.ForegroundMismatch && foreground.Client.Known && foreground.Client.Window != 0 +} + +func finishedLoadingText(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "Finished loading." + } + return "Finished loading " + name + "." +} diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go new file mode 100644 index 0000000..5b5adef --- /dev/null +++ b/internal/app/event_loop.go @@ -0,0 +1,324 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func processEvents( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + debounce time.Duration, + configuredPresentation ...presentation.Options, +) error { + return processEventsWithReview(ctx, logger, speech, adapter, nil, debounce, configuredPresentation...) +} + +func processEventsWithReview( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + reviewRequests <-chan struct{}, + debounce time.Duration, + configuredPresentation ...presentation.Options, +) error { + return processEventsWithObjectReview(ctx, logger, speech, adapter, reviewRequests, nil, debounce, configuredPresentation...) +} + +func processEventsWithObjectReview( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + whereAmIRequests <-chan struct{}, + objectReview *review.Worker, + debounce time.Duration, + configuredPresentation ...presentation.Options, +) error { + return processEventsWithSettings(ctx, logger, speech, adapter, whereAmIRequests, objectReview, debounce, nil, configuredPresentation...) +} + +func processEventsWithSettings( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + whereAmIRequests <-chan struct{}, + objectReview *review.Worker, + debounce time.Duration, + live *liveSettings, + configuredPresentation ...presentation.Options, +) error { + return processEventsWithInput(ctx, logger, speech, adapter, whereAmIRequests, objectReview, debounce, live, nil, nil, nil, configuredPresentation...) +} + +func processEventsWithInput( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + whereAmIRequests <-chan struct{}, + objectReview *review.Worker, + debounce time.Duration, + live *liveSettings, + inputSource any, + focusGate *focusGate, + notifications *notification.Presenter, + configuredPresentation ...presentation.Options, +) error { + return processEventsWithOCR(ctx, logger, speech, adapter, whereAmIRequests, objectReview, debounce, live, inputSource, focusGate, notifications, nil, configuredPresentation...) +} + +func processEventsWithOCR( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + whereAmIRequests <-chan struct{}, + objectReview *review.Worker, + debounce time.Duration, + live *liveSettings, + inputSource any, + focusGate *focusGate, + notifications *notification.Presenter, + ocrSession *ocrSession, + configuredPresentation ...presentation.Options, +) error { + return processEventsWithAttributes(ctx, logger, speech, adapter, whereAmIRequests, nil, objectReview, debounce, live, inputSource, focusGate, notifications, ocrSession, configuredPresentation...) +} + +func processEventsWithAttributes( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + adapter Adapter, + whereAmIRequests <-chan struct{}, + attributeRequests <-chan struct{}, + objectReview *review.Worker, + debounce time.Duration, + live *liveSettings, + inputSource any, + focusGate *focusGate, + notifications *notification.Presenter, + ocrSession *ocrSession, + configuredPresentation ...presentation.Options, +) error { + attributes := startCharacterAttributeReader(ctx, adapter) + defer attributes.stop() + if notifications == nil { + notifications = notification.NewPresenter() + } + state := eventLoopState{} + state.presentationOptions = presentation.DefaultOptions() + if len(configuredPresentation) != 0 { + state.presentationOptions = configuredPresentation[0] + } + var brailleOutput brailleFrameEnqueuer + if queue, ok := speech.(outputQueue); ok { + brailleOutput = queue.braille + } + webScripts := &webScriptController{speech: speech, notifications: notifications, live: live, gate: focusGate} + webScripts.braille, _ = brailleOutput.(interface { + Flash(string, config.BrailleOptions) bool + }) + defer webScripts.reset() + logger.Info("listening for focus events") + browser, _ := inputSource.(*browserModeController) + var modeResults <-chan browserModeResult + var modeToggles <-chan input.Command + if browser != nil { + modeResults = browser.results + modeToggles = browser.toggles + } + foreground, _ := adapter.(foregroundMonitor) + var foregroundChanges <-chan struct{} + if foreground != nil { + foregroundChanges = foreground.ForegroundChanges() + state.foregroundState = foreground.Foreground() + } + titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground} + titles.source, _ = adapter.(windowTitleSource) + var titleChanges <-chan struct{} + if titles.source != nil { + titleChanges = titles.source.TitleChanges() + titles.nativeChanged() + } + var wineTitles <-chan wineWindowTitle + if live != nil { + wineTitles = live.wineTitles + } + defer titles.reset() + defer state.pending.clear() + var objectReviewResults <-chan review.Result + if objectReview != nil { + objectReviewResults = objectReview.Results() + } + + presenter := eventPresenter{ + state: &state, logger: logger, speech: speech, foreground: foreground, + inputSource: inputSource, live: live, browser: browser, + } + speak := presenter.speak + flushTerminalDeletion := func() { + if state.pending.terminalDeletion.event.Type == "" { + return + } + event, _ := state.pending.terminalDeletion.take() + speak(event) + } + clearOwnedFocus := func() { + ocrSession.Retire() + titles.reset() + state.clearOwnedFocus(brailleOutput, browser, objectReview, speech) + } + var wineReview *wineReviewFocusController + var wineReviewChanges <-chan struct{} + if live != nil && live.wineReview != nil { + wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearOwnedFocus} + wineReviewChanges = live.wineReview.Changes() + wineReview.changed() + defer wineReview.retire() + } + syncForeground := func() { + state.syncForeground(foreground, logger, clearOwnedFocus) + if wineReview != nil { + wineReview.foregroundChanged() + } + } + reviewHandler := eventReviewHandler{ + state: &state, logger: logger, speech: speech, objectReview: objectReview, + browser: browser, brailleOutput: brailleOutput, live: live, + focusGate: focusGate, syncForeground: syncForeground, speak: speak, + wineReview: wineReview, + } + eventHandler := accessibilityEventHandler{ + state: &state, titles: titles, logger: logger, speech: speech, adapter: adapter, + objectReview: objectReview, browser: browser, inputSource: inputSource, + focusGate: focusGate, notifications: notifications, live: live, + brailleOutput: brailleOutput, foreground: foreground, debounce: debounce, + clearOwnedFocus: clearOwnedFocus, syncForeground: syncForeground, + flushTerminalDeletion: flushTerminalDeletion, speak: speak, webScripts: webScripts, + wineReview: wineReview, + } + + var lockChanges <-chan struct{} + if focusGate != nil { + lockChanges = focusGate.lockChanges + } + for { + select { + case <-ctx.Done(): + return nil + case <-lockChanges: + if wineReview != nil { + wineReview.retire() + } + webScripts.reset() + state.owner.clear() + clearOwnedFocus() + case <-wineReviewChanges: + syncForeground() + case <-webScripts.due: + webScripts.flush() + case <-foregroundChanges: + focusGate.retireTentacleOutput() + ocrSession.ForegroundChanged() + syncForeground() + if titles.source != nil { + titles.nativeChanged() + } + case <-titleChanges: + focusGate.retireTentacleOutput() + syncForeground() + titles.nativeChanged() + case update := <-wineTitles: + syncForeground() + titles.wineChanged(update) + case <-titles.due: + syncForeground() + titles.flush() + case result := <-modeResults: + focusGate.retireTentacleOutput() + if focusGate.Locked() { + continue + } + syncForeground() + if result.err != nil { + logger.Debug("browser mode outside web document", "reason", result.err) + } + browser.accept(result, speech) + case command := <-modeToggles: + focusGate.retireTentacleOutput() + if focusGate.Locked() { + continue + } + syncForeground() + switch command { + case input.StickyFocusMode: + browser.sticky(review.FocusMode, speech) + case input.StickyBrowseMode: + browser.sticky(review.BrowseMode, speech) + default: + browser.toggle(speech) + } + case event, ok := <-adapter.Events(): + focusGate.retireTentacleOutput() + if !ok { + return nil + } + if !ocrSession.Observe(event) { + eventHandler.handle(event) + } + case <-state.pending.caret.ready: + if focusGate.Locked() { + state.pending.clear() + continue + } + event, intent := state.pending.caret.take() + speak(event, intent) + case <-state.pending.terminalFocus.ready: + if focusGate.Locked() { + state.pending.clear() + continue + } + event, _ := state.pending.terminalFocus.take() + speak(event) + case <-state.pending.terminalDeletion.ready: + if focusGate.Locked() { + state.pending.clear() + continue + } + event, _ := state.pending.terminalDeletion.take() + speak(event) + case <-whereAmIRequests: + reviewHandler.whereAmI() + case <-attributeRequests: + if !focusGate.Locked() { + syncForeground() + attributes.request(state.current.ID(), recentInput(inputSource).Sequence) + } + case result := <-attributes.results: + if !focusGate.Locked() && result.object == state.current.ID() && result.object.Valid() && result.sequence == recentInput(inputSource).Sequence { + settings := live.Load() + presentCharacterAttributes(speech, result, settings.Reading.UseColorNames, settings.TextAttributes) + } + case result := <-objectReviewResults: + reviewHandler.handle(result) + } + } +} diff --git a/internal/app/event_navigation_filters.go b/internal/app/event_navigation_filters.go new file mode 100644 index 0000000..7c1a700 --- /dev/null +++ b/internal/app/event_navigation_filters.go @@ -0,0 +1,98 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// filterNavigationAndNames runs after selected-tab presentation and before +// autocomplete, terminal coalescing, or focus/text presentation. +func (handler *accessibilityEventHandler) filterNavigationAndNames(event atspi.Event, activity input.Activity, intent input.Intent) bool { + state := handler.state + logger := handler.logger + browser := handler.browser + ownedAutocompletePopup := state.ownedAutocompletePopup + // A suggestion popup may clear the entry's native focused bit. + // Preserve user edits in the owned entry, but reject background + // URL rewrites unless input confirms an edit in the owned entry. + if isTextChangeEvent(event.Type) && event.Source.Autocomplete && + (event.Source.ID() != state.focusedID || + intent != input.TypingIntent && intent != input.DeleteIntent && + activity.Clipboard != input.PasteAction && activity.Clipboard != input.CutAction) { + logger.Debug("skipped autocomplete text change without owned edit", "type", event.Type) + return true + } + if browser != nil && event.Type == atspi.CaretEvent && !event.Source.Editable && + state.current.Editable && event.Source.ID() != state.current.ID() && + !(browser.ready && browser.mode == review.BrowseMode) { + // Chromium can deliver a queued document caret after focus has + // moved to the address bar or an entry. It must not reacquire + // browse grabs while the user is typing in that control. + logger.Debug("skipped document caret after editable focus", "role", event.Source.Role) + return true + } + if handler.titles.accessible(event) { + return true + } + if isBrowserUINameNoise(event, state.current) { + logger.Debug("skipped browser UI name event", "type", event.Type, "role", event.Source.Role) + return true + } + if event.Type == atspi.AccessibleNameEvent && event.Source.Role == "table cell" && + (!event.Source.GUITable || event.Source.ID() != state.focusedID && event.Source.ID() != state.managedDescendantID || + sameManagedDescendant(event.Source, state.current)) { + // Ignore unchanged or background cells, including relative timestamps + // in old feed rows. A reused current row needs its new content read. + logger.Debug("skipped table cell name event") + return true + } + // Python presents ordinary name changes only for the locus of focus. + // Containers and status bars can rename on every file selection; + // presenting those updates would interrupt the focused file name. + // Thunar's unnamed-panel selection fallback is handled above. + if event.Type == atspi.AccessibleNameEvent && + !event.Source.LiveRegion && event.Source.ID() != state.focusedID && + !(presentsReusedRow(event.Source) && event.Source.ID() == state.managedDescendantID) { + logger.Debug("skipped name change outside focused object", "role", event.Source.Role) + return true + } + if browser != nil && browser.retainsBrowseCaret(event) && event.Type == atspi.CaretEvent && + !event.Source.Editable && !activity.Selecting && + (activity.Intent == input.CharacterIntent || activity.Intent == input.WordIntent || activity.Intent == input.LineIntent) { + // Unshifted browse arrows are handled by Go. A delayed native + // echo from an earlier move must not reset the next queued move. + logger.Debug("skipped native caret during Go browse navigation") + return true + } + if browser != nil && browser.suppressNavigationEcho(event) { + logger.Debug("skipped structural navigation echo", "type", event.Type, "role", event.Source.Role) + return true + } + retainBrowseCaret := browser != nil && browser.ready && browser.mode == review.BrowseMode && + event.Type != atspi.WindowActivatedEvent && !isMenuRole(event.Source.Role) && + isFocusEvent(event.Type) && !event.Source.Editable && + (!atspi.IsWebDocumentRole(event.Source.Role) || event.Source.ID() == browser.document) && + (atspi.IsWebDocumentRole(event.Source.Role) || !event.Source.Focusable || + intent == input.LineIntent || intent == input.WordIntent || intent == input.CharacterIntent) + if retainBrowseCaret { + // Chromium can re-emit its retained DOM focus after native caret + // movement. Python keeps the independent browse caret in this case; + // following the delayed focus would briefly release the browse grabs + // and reset structural navigation to the old focused object. + logger.Debug("skipped retained browser focus during caret navigation", "role", event.Source.Role) + return true + } + if browser != nil && isFocusEvent(event.Type) && !ownedAutocompletePopup(event) { + browser.nativeButtonFocus(isFocusGainEvent(event) && (event.Source.Role == "button" || event.Source.Role == "push button" || event.Source.Role == "toggle button")) + browser.follow(event.Source.ID()) + } else if browser != nil && event.Type == atspi.CaretEvent && !event.Source.Editable { + browser.followCaret(event.Source.ID()) + } + return false +} diff --git a/internal/app/event_notification.go b/internal/app/event_notification.go new file mode 100644 index 0000000..9645a2d --- /dev/null +++ b/internal/app/event_notification.go @@ -0,0 +1,128 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strconv" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/notification" +) + +func presentNotifications(speech speechEnqueuer, messages []string) { + speech.Interrupt() + for _, message := range messages { + // Notifications remain audible while XTerm owns keyboard handling. + // The output gate still rejects these requests during screen lock. + speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "notifications", BypassFocusGate: true}) + } +} + +type notificationAnnouncer struct { + presenter *notification.Presenter + speech speechEnqueuer + braille interface { + Flash(string, config.BrailleOptions) bool + } + live *liveSettings +} + +func (announcer *notificationAnnouncer) Announce(entry notification.Entry) { + if announcer.braille != nil && announcer.live != nil { + options := announcer.live.Load().Braille + message := entry.Message + if !options.FlashDetailed && entry.Summary != "" { + message = entry.Summary + } + announcer.braille.Flash(message, options) + } + announcer.presenter.Save(entry.Message, notification.SaveOptions{ + Source: entry.Source, + SourceGeneration: entry.SourceGeneration, + NotificationID: entry.NotificationID, + Live: entry.Live, + Actions: entry.Actions, + AppName: entry.AppName, + Summary: entry.Summary, + Body: entry.Body, + Urgency: entry.Urgency, + DesktopEntry: entry.DesktopEntry, + }) + announcer.speech.Interrupt() + message := entry.Message + if announcer.live != nil && !announcer.live.Load().Reading.MessagesAreDetailed && entry.Summary != "" { + message = entry.Summary + } + announcer.speech.Enqueue(speechRequest{ + Text: message, Role: "system", Cause: "mako-notification", + }) +} + +func notificationEvent(event atspi.Event) (string, []string, bool, bool) { + applicationName := strings.ToLower(event.Source.ApplicationName) + if event.Type == atspi.WindowCreateEvent { + switch applicationName { + case "notification-daemon", "mate-notification-daemon", "xfce4-notifyd": + message := notification.MessageNotification + if event.Source.Text != "" { + message += " " + event.Source.Text + } + return message, []string{message}, true, true + } + } + + if applicationName == "notify-osd" { + if event.Type == atspi.AccessibleNameEvent { + message := event.Source.Name + " " + event.Source.Description + if !event.Source.ValueKnown || event.Source.CurrentValue < 0 { + return message, []string{notification.MessageNotification, message}, true, true + } + message = event.Source.Name + " " + strconv.Itoa(int(event.Source.CurrentValue)) + return message, []string{message, message}, true, true + } + if event.Type == atspi.ValueChangedEvent || event.Type == atspi.AccessibleValueEvent { + if !event.Source.ValueKnown || event.Source.CurrentValue < 0 { + return "", nil, false, false + } + value := strconv.Itoa(int(event.Source.CurrentValue)) + return value, []string{value}, true, false + } + } + + if event.Type == atspi.ShowingStateEvent && + strings.EqualFold(event.Source.Role, "notification") && event.Detail1 != 0 { + return notificationContent(event.Source), nil, false, true + } + return "", nil, false, false +} + +func notificationContent(snapshot atspi.Snapshot) string { + parts := []string{snapshot.Name} + if snapshot.Text != "" && snapshot.Text != snapshot.Name { + parts = append(parts, snapshot.Text) + } + if len(parts) == 1 && parts[0] == "" && snapshot.Description != "" { + parts = []string{snapshot.Description} + } + var content []string + for _, part := range parts { + if part != "" && !containsString(content, part) { + content = append(content, part) + } + } + return strings.Join(content, " ") +} + +func containsString(values []string, candidate string) bool { + for _, value := range values { + if value == candidate { + return true + } + } + return false +} diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go new file mode 100644 index 0000000..70202d8 --- /dev/null +++ b/internal/app/event_presentation.go @@ -0,0 +1,245 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + "strconv" + "strings" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// eventPresenter owns presentation policy but mutates only its event-loop state. +type eventPresenter struct { + state *eventLoopState + logger *slog.Logger + speech speechEnqueuer + foreground foregroundMonitor + inputSource any + live *liveSettings + browser *browserModeController +} + +func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...input.Intent) { + state := presenter.state + logger := presenter.logger + speech := presenter.speech + foreground := presenter.foreground + inputSource := presenter.inputSource + live := presenter.live + browser := presenter.browser + ownedAutocompletePopup := state.ownedAutocompletePopup + if foreground != nil && !eventBypassesXTermHandoff(event) && + (event.Foreground.Epoch != foreground.Foreground().Epoch || + event.ForegroundMismatch && !ownedAutocompletePopup(event)) { + return + } + var settings config.Settings + if live != nil { + settings = live.Load() + available := state.presentationOptions.SoundCueAvailable + state.presentationOptions = presentationSettingsFromConfig(settings, true) + state.presentationOptions.SoundCueAvailable = available + } + echoKind := "" + comboValue, comboUpdate, comboDuplicate := state.comboHistory.observe(event) + if comboDuplicate { + return + } + sequence := recentInput(inputSource).Sequence + intent := recentInput(inputSource).Intent + if len(capturedIntent) != 0 { + intent = capturedIntent[0] + } + table := state.prepareGUITable(event, sequence) + if table.skip { + return + } + spoken := presentation.EventSpeechWithOptions(event, table.options) + spoken, appendTableFocus := state.decorateGUITable(event, spoken, table) + text := spoken.Text + prompt := newPromptPresentation(state, event) + if prompt.activation { + spoken = presentation.SpeechWithOptions([]string{strings.TrimSpace(event.WindowTitle), event.WindowMessage}, table.options) + text = spoken.Text + } + if comboUpdate { + text = comboValue + } + if event.Type == atspi.CaretEvent { + intent := recentInput(inputSource).Intent + if len(capturedIntent) != 0 { + intent = capturedIntent[0] + } + switch intent { + case input.LineIntent: + text = event.Source.Text + if text == "" && event.Source.CaretCharacter != "" && strings.TrimSpace(event.Source.CaretCharacter) == "" { + text = "blank" + } + case input.WordIntent: + text = event.Source.CaretWord + } + } + if event.Type == atspi.TextInsertedEvent { + if event.Source.LiveRegion { + text = strings.TrimSpace(event.Payload) + if event.Source.LiveAtomic && event.Source.Text != "" { + text = strings.TrimSpace(event.Source.Text) + } + } else { + previousText := "" + if event.Source.Path == state.typingPath { + previousText = state.typingText + } + text, echoKind = presentation.TypingEcho(event, previousText, state.presentationOptions) + } + } + if event.Type == atspi.TextDeletedEvent && utf8.RuneCountInString(event.Payload) == 1 && text == event.Payload { + // Deleting one letter needs the same uppercase voice as character + // review. Keep whitespace labels and multi-character deletions as text. + echoKind = "character" + } + if event.Type == atspi.ChildrenAddedEvent && event.Source.LiveRegion { + text = strings.TrimSpace(event.Source.Text) + } + if event.Source.LiveRegion { + text = strings.TrimSpace(strings.ReplaceAll(text, "\ufffc", "")) + } + if speaksTextChanges(event.Source) && event.Source.Path != "" && !event.Source.LiveRegion { + state.typingPath = event.Source.Path + state.typingText = event.Source.TextBeforeCaret + if state.typingText == "" && event.Payload != "\n" && event.Payload != "\t" { + state.typingText = event.Source.Text + } + } + if event.Type == atspi.CaretEvent && (intent == input.WordIntent || intent == input.LineIntent) && event.Source.Misspelled && + settings.Reading.SpeakMisspelledIndicator && !settings.Reading.OnlySpeakDisplayedText { + spoken = presentation.SpeechWithOptions([]string{strings.TrimSuffix(text, ", misspelled"), "misspelled"}, table.options) + text = spoken.Text + } + cues := []sound.Cue(nil) + fallbackSpeech := presentationFallbackSpeech(event, state.presentationOptions) + fallbackText := fallbackSpeech.Text + if prompt.activation { + fallbackText = text + fallbackSpeech = spoken.WithText(text) + } + if comboUpdate { + fallbackText = text + fallbackSpeech = spoken.WithText(text) + } + if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable { + intent := recentInput(inputSource).Intent + if len(capturedIntent) != 0 { + intent = capturedIntent[0] + } + if intent == input.LineIntent && event.Source.Text != "" { + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: event.Source, Text: event.Source.Text}}} + spoken = presentation.DocumentLineSpeechWithOptions(line, state.presentationOptions) + text = spoken.Text + cues = presentation.DocumentLineSoundCues(line, state.presentationOptions) + fallbackOptions := state.presentationOptions + fallbackOptions.RoleSoundPresentation = "sound_and_speech" + fallbackSpeech = presentation.DocumentLineSpeechWithOptions(line, fallbackOptions) + fallbackText = fallbackSpeech.Text + } + } + if event.Type != atspi.CaretEvent && !isTextChangeEvent(event.Type) { + cues = presentation.EventSoundCues(event, state.presentationOptions) + if event.Source.Role == "combo box" && (comboUpdate || !isFocusGainEvent(event)) { + cues = nil + } + + } + if prompt.activation { + // The control's own focus event will present its role and cue. + cues = nil + } + cueParts := make([]string, 0, len(cues)) + for _, cue := range cues { + cueParts = append(cueParts, cue.Name+":"+strconv.FormatFloat(cue.Pan, 'g', -1, 64)) + } + spoken, fallbackSpeech = prompt.focusSpeech(spoken.WithText(text), fallbackSpeech.WithText(fallbackText)) + text, fallbackText = spoken.Text, fallbackSpeech.Text + cueKey := strings.Join(cueParts, "\x00") + // Adjacent characters can have the same text while the caret moves. + // Treat each character caret event as a new review position. + characterCaret := event.Type == atspi.CaretEvent && text != "" && text == event.Source.CaretCharacter + if !characterCaret && !isTextChangeEvent(event.Type) && + event.Source.Path != "" && event.Source.Path == state.lastSpokenPath && text == state.lastSpokenText && cueKey == state.lastSound { + return + } + if event.Source.LiveRegion && event.Source.LivePoliteness == "off" { + return + } + if event.Source.LiveRegion && text != "" && text == state.lastLiveText && time.Since(state.lastLiveAt) < 300*time.Millisecond { + return + } + if text == "" && len(cues) == 0 { + logger.Debug("accessibility event has no speech text", "type", event.Type, "role", event.Source.Role) + return + } + appendPromptFocus := prompt.appendFocus() + prompt.finishFocus() + custom := false + if live != nil { + custom = customEcho(settings, echoKind) + } + appendTabFocus := isFocusGainEvent(event) && eventWindow(event) == state.tabFocusWindow && + state.tabFocusSequence != 0 && sequence == state.tabFocusSequence + if isFocusGainEvent(event) { + state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0 + } + var line *speechLine + if event.Source.Role != "password text" && event.Source.TextCaretKnown && + (event.Type == atspi.CaretEvent && intent == input.LineIntent || isFocusGainEvent(event)) { + line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine} + } + speech.Enqueue(speechRequest{ + Context: event.Source.SpeechContext, VoiceType: ancestor_voice(event.Source.SpeechContext), + Window: eventWindow(event), + IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) && + activationTitle(event) != "", + Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: event.Source.Role, Sound: cues, Cause: event.Type, CustomEcho: custom, + Line: line, + ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow, + Character: event.Type == atspi.CaretEvent && text == event.Source.CaretCharacter || echoKind == "character" || echoKind == "key", + Append: appendTabFocus || appendTableFocus || appendPromptFocus || event.Source.LiveRegion && event.Source.LivePoliteness == "polite" || + event.Source.Role == "terminal" && event.Type == atspi.TextInsertedEvent, + BypassFocusGate: eventBypassesXTermHandoff(event), + }) + state.rememberGUITable(event, table) + if event.Source.LiveRegion { + state.lastLiveText, state.lastLiveAt = text, time.Now() + } + logger.Debug("queued accessible presentation", "type", event.Type, "role", event.Source.Role) + prompt.rememberWindow() + if !isTextChangeEvent(event.Type) { + state.lastSpokenPath = event.Source.Path + state.lastSpokenText = text + state.lastSound = cueKey + } +} + +func presentationFallbackText(event atspi.Event, options presentation.Options) string { + return presentationFallbackSpeech(event, options).Text +} + +func presentationFallbackSpeech(event atspi.Event, options presentation.Options) presentation.Speech { + if options.RoleSoundPresentation != "sound_only" { + return presentation.Speech{} + } + options.RoleSoundPresentation = "sound_and_speech" + return presentation.EventSpeechWithOptions(event, options) +} diff --git a/internal/app/event_presentation_test.go b/internal/app/event_presentation_test.go new file mode 100644 index 0000000..5090a9a --- /dev/null +++ b/internal/app/event_presentation_test.go @@ -0,0 +1,181 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/logging" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestProcessEventsDoesNotBlockOnDiagnosticsWriter(t *testing.T) { + writer := &eventDiagnosticsWriter{ + started: make(chan struct{}), + canceled: make(chan struct{}), + } + diagnostics := logging.NewAsync(writer, logging.AsyncOptions{ + Options: logging.Options{Level: slog.LevelDebug}, + Capacity: 2, + StopTimeout: 100 * time.Millisecond, + CancelGrace: 10 * time.Millisecond, + }) + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, diagnostics.Logger(), speech, adapter, 0) + close(completed) + }() + + adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/test", "Test", "label", "", "")) + select { + case <-writer.started: + case <-time.After(time.Second): + t.Fatal("diagnostics worker did not start") + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event consumer blocked on diagnostics writer") + } + + writer.Cancel() + if err := diagnostics.Stop(context.Background()); err != nil { + t.Fatalf("diagnostics stop: %v", err) + } +} + +func TestProcessEventsSuppressesBrowserAccessibleNameEvents(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + browser := newBrowserModeController(nil, &modeKeys{}, nil) + documentContext := modeContext(t, "heading", false) + browser.navigated(review.DocumentPosition{ + Document: documentContext.TopLevel, + Snapshot: documentContext.Ancestors[0], + }, documentContext.Ancestors[0].ID()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, browser, nil, nil) + close(completed) + }() + + browserName := atspi.NewSnapshot("/browser/static", "https://example.com/", "static", "", "") + browserName.BusName = documentContext.TopLevel.ID().BusName + adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, browserName) + select { + case text := <-speech.spoken: + t.Fatalf("browser name event spoke %q", text) + case <-time.After(50 * time.Millisecond): + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestProcessEventsAppliesDescriptionSetting(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents( + ctx, + slog.New(slog.DiscardHandler), + speech, + adapter, + 0, + presentation.Options{SpeakDescription: false}, + ) + close(completed) + }() + + adapter.events <- atspi.NewEvent( + "focus:", + atspi.NewSnapshot("/accessible/save", "Save", "push button", "Saves the document", ""), + ) + select { + case text := <-speech.spoken: + if text != "Save, push button" { + t.Fatalf("spoken text = %q, want %q", text, "Save, push button") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for configured focus speech") + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestProcessEventsSpeaksChangedTextImmediately(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "prompt ls") + snapshot.Editable = true + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "s") + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, "s") + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, " ") + + for _, expected := range []string{"s", "s", "space"} { + select { + case text := <-speech.spoken: + if text != expected { + t.Fatalf("spoken text = %q, want %q", text, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for changed text speech %q", expected) + } + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go new file mode 100644 index 0000000..8671a7b --- /dev/null +++ b/internal/app/event_review_results.go @@ -0,0 +1,278 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// eventReviewHandler consumes review results on the event-loop goroutine. +type eventReviewHandler struct { + state *eventLoopState + logger *slog.Logger + speech speechEnqueuer + objectReview *review.Worker + browser *browserModeController + brailleOutput brailleFrameEnqueuer + live *liveSettings + focusGate *focusGate + syncForeground func() + speak func(atspi.Event, ...input.Intent) + wineReview *wineReviewFocusController +} + +func (handler *eventReviewHandler) whereAmI() { + if handler.focusGate.Locked() { + return + } + handler.syncForeground() + handler.refreshPresentation() + state := handler.state + state.pending.terminalFocus.clear() + state.pending.terminalDeletion.clear() + // Python's Where Am I is speech-only. Do not suppress the role merely + // because ordinary focus presentation replaces it with a sound cue. + reviewOptions := state.presentationOptions + reviewOptions.EnableSound = false + spoken := presentation.FocusSpeechWithOptions(state.current, reviewOptions) + text := spoken.Text + role := state.current.Role + if text == "" { + text = "Could not find current location." + role = "system" + } + handler.speech.Interrupt() + handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"}) + handler.logger.Debug("queued where am I presentation", "role", role) +} + +func (handler *eventReviewHandler) handle(result review.Result) { + handler.refreshPresentation() + state := handler.state + logger := handler.logger + speech := handler.speech + objectReview := handler.objectReview + browser := handler.browser + brailleOutput := handler.brailleOutput + live := handler.live + focusGate := handler.focusGate + syncForeground := handler.syncForeground + speak := handler.speak + if focusGate.Locked() { + return + } + if result.DocumentNativeCaret { + logger.Debug("native document caret synchronized", "source_bus", result.DocumentFocus.BusName, "source_path", result.DocumentFocus.Path, + "target_path", result.DocumentPosition.Object.ID().Path, "virtual_caret_offset", result.DocumentPosition.Offset, "error", result.Err) + return + } + syncForeground() + if !state.current.ID().Valid() { + return + } + if result.ReadAllPosition { + if objectReview.CurrentDocumentResult(result) && result.DocumentFocus == objectReview.Focus() { + state.current = result.DocumentPosition.Snapshot + state.focusedID = state.current.ID() + } + return + } + if result.ReadAllContext != nil { + if objectReview.CurrentDocumentResult(result) && result.ReadAllContext.Err() == nil && result.DocumentFocus == objectReview.Focus() { + presentReadAllResult(logger, speech, result, state.presentationOptions) + } + return + } + if (result.DocumentLine || result.DocumentTarget != 0 || result.DocumentCaretCommand != 0 || result.DocumentActivate || result.DocumentFragment) && !objectReview.CurrentDocumentResult(result) { + return + } + if result.DocumentFragment && (!result.Moved || result.Err != nil) { + if result.Err != nil { + logger.Warn("document fragment navigation failed", "error", result.Err) + } + return + } + if result.DocumentActivate { + if browser == nil || !browser.acceptsDocumentResult(result) { + return + } + if result.Err != nil { + logger.Debug("document activation failed", "error", result.Err) + speech.Interrupt() + speech.Enqueue(speechRequest{Text: "Could not activate current item.", Role: "system", Cause: "document-activate"}) + } else { + logger.Debug("document activation completed", "elapsed", result.Elapsed) + } + return + } + if result.DocumentLine { + if result.DocumentLineRequest == 0 || result.DocumentLineRequest != state.documentLineRequest || + !result.DocumentLineFocus.Valid() || result.DocumentLineFocus != state.documentLineFocus { + return + } + event := state.documentLineEvent + state.documentLineFocus = atspi.ObjectID{} + state.documentLineRequest = 0 + state.documentLineEvent = atspi.Event{} + if result.Err != nil { + logger.Debug("document caret line assembly failed", "error", result.Err) + speak(event, input.LineIntent) + return + } + spoken := presentation.DocumentLineSpeechWithOptions(result.DocumentLineData, state.presentationOptions) + text := spoken.Text + cues := presentation.DocumentLineSoundCues(result.DocumentLineData, state.presentationOptions) + fallbackOptions := state.presentationOptions + fallbackOptions.RoleSoundPresentation = "sound_and_speech" + fallback := presentation.DocumentLineSpeechWithOptions(result.DocumentLineData, fallbackOptions) + speech.Interrupt() + if text == "" && len(cues) == 0 { + return + } + speech.Enqueue(speechRequest{Text: text, Chunks: spoken.Chunks, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks, Sound: cues, Role: "text", Cause: "document-caret-line", Line: document_speech_line(result.DocumentLineData), Context: result.DocumentLineData.Context, VoiceType: document_line_voice(result.DocumentLineData)}) + return + } + if result.DocumentTarget != 0 || result.DocumentCaretCommand != 0 || result.DocumentFragment { + if browser != nil { + if !browser.acceptsDocumentResult(result) { + return + } + } else if !documentResultMatchesCurrent(result, state.current) { + return + } + // The native caret event which preceded this result can still be + // waiting in the coalescing timer. Its old text must not replace + // the structural result after the jump completes. + state.pending.clear() + if result.DocumentCaretCommand != 0 { + if !result.DocumentBrailleRoute { + presentDocumentCaretResult(logger, speech, result, state.presentationOptions) + } + } else { + presentDocumentResult(logger, speech, result, state.presentationOptions) + } + if result.Moved && result.Err == nil && result.CaretErr == nil { + if result.DocumentCaretCommand != review.CaretReadLine || result.DocumentBrailleRoute { + state.documentSelection = textSelection{} + } + state.current = result.DocumentPosition.Snapshot + if brailleOutput != nil { + state.brailleSnapshot = state.current + brailleOptions := config.DefaultSettings().Braille + textAttributes := config.DefaultTextAttributes() + if live != nil { + settings := live.Load() + brailleOptions, textAttributes = settings.Braille, settings.TextAttributes + } + brailleOutput.EnqueueFrame(brailleDocumentFrame(result, brailleOptions, textAttributes)) + } + state.focusedID = state.current.ID() + // Browse navigation does not change native focus. Keep the + // worker on its document anchor; dynamic pages can remove the + // spoken leaf before the next review command. + if result.DocumentCaretCommand == 0 && browser == nil { + objectReview.SyncFocus(state.current.ID()) + } + if browser != nil { + objectReview.SetFlatOrigin(state.current.ID(), result.DocumentPosition.Offset) + browser.navigated(result.DocumentPosition, objectReview.Focus()) + } + } + } else if result.FlatUnit != 0 { + if reservedWineID(result.FlatFocus) && (handler.wineReview == nil || !handler.wineReview.resultCurrent(result)) { + return + } + // The browse caret can be a leaf while native focus remains on + // the document. Flat review belongs to that native review focus. + if !flatReviewResultCurrent(objectReview, result) { + logger.Debug("discarded stale flat review result", "result_focus", result.FlatFocus.Path, + "current_focus", objectReview.Focus().Path, "document_epoch", result.DocumentEpoch) + return + } + state.pending.terminalFocus.clear() + state.pending.terminalDeletion.clear() + presentFlatReviewResult(logger, speech, result) + if reservedWineID(result.FlatFocus) && brailleOutput != nil && result.Err == nil && + (result.Moved || result.FlatStep == 0 && result.FlatUnit != review.WordAbove && result.FlatUnit != review.WordBelow) { + options := config.DefaultSettings().Braille + if live != nil { + options = live.Load().Braille + } + // Review is independent of the native caret. Display through the + // existing queue with no native text routes or document navigation. + brailleOutput.EnqueueFrame(braille.Frame{Text: result.FlatPosition.Text, Cursor: 0, WordWrap: options.WordWrap}) + } + } else { + presentObjectReviewResult(logger, speech, result, state.presentationOptions) + } +} + +func (handler *eventReviewHandler) refreshPresentation() { + if handler.live == nil { + return + } + available := handler.state.presentationOptions.SoundCueAvailable + handler.state.presentationOptions = presentationSettingsFromConfig(handler.live.Load(), true) + handler.state.presentationOptions.SoundCueAvailable = available +} + +func flatReviewResultCurrent(worker *review.Worker, result review.Result) bool { + return worker != nil && result.FlatFocus.Valid() && + worker.CurrentDocumentResult(result) && result.FlatFocus == worker.Focus() +} + +func queueObjectReviewMove(logger *slog.Logger, worker *review.Worker, direction review.Direction) { + logger.Info("object review command received", "direction", direction.String()) + if err := worker.Move(direction); err != nil { + logger.Warn("queue object review movement", "direction", direction.String(), "error", err) + } +} + +func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { + text := "" + var spoken presentation.Speech + role := "system" + if result.Err != nil { + logger.Warn("object review movement failed", "direction", result.Direction.String(), "error", result.Err) + text = "Could not find current location." + } else if !result.Moved { + text = objectReviewBoundaryMessage(result.Direction) + } else { + options.EnableSound = false + spoken = presentation.FocusSpeechWithOptions(result.Position.Current, options) + text = spoken.Text + role = result.Position.Current.Role + if text == "" { + text = "Could not find current location." + role = "system" + } + } + speech.Interrupt() + speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "object-review"}) + logger.Debug("queued object review presentation", "direction", result.Direction.String(), "moved", result.Moved, "role", role) +} + +func objectReviewBoundaryMessage(direction review.Direction) string { + switch direction { + case review.Parent: + return "No parent." + case review.FirstChild: + return "No children." + case review.PreviousSibling: + return "No previous." + case review.NextSibling: + return "No next." + default: + return "Could not find current location." + } +} diff --git a/internal/app/event_state.go b/internal/app/event_state.go new file mode 100644 index 0000000..2dc7504 --- /dev/null +++ b/internal/app/event_state.go @@ -0,0 +1,187 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "log/slog" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// eventLoopState is owned only by processEventsWithInput's goroutine. Embedded +// groups keep each lifetime visible while retaining the existing individual +// reset transitions. Workers observe state only through their existing APIs. +type eventLoopState struct { + eventForegroundState + eventFocusState + eventDocumentState + eventInputState + eventPresentationState +} + +type eventForegroundState struct { + owner windowOwner + foregroundState atspi.ForegroundState + lastOverflow uint64 +} + +type eventFocusState struct { + current atspi.Snapshot + brailleSnapshot atspi.Snapshot + focusedID atspi.ObjectID + managedContainerID atspi.ObjectID + managedDescendantID atspi.ObjectID +} + +type eventDocumentState struct { + selectedBrowserTab atspi.ObjectID + tabFocusWindow atspi.ObjectID + tabFocusSequence uint64 + documentLineFocus atspi.ObjectID + documentLineRequest uint64 + documentLineEvent atspi.Event + loadedDocument atspi.ObjectID + loadingDocument atspi.ObjectID + // A load-complete can verify chrome focus just before a busy:false event + // temporarily loses its native focused state. Keep that document from + // taking browse keys until focus actually enters it. + chromeLoadedDocument atspi.ObjectID + chromeLoadedWindow atspi.ObjectID +} + +type eventInputState struct { + lastAutocompletePopupItem atspi.ObjectID + focusCaretID atspi.ObjectID + focusCaretSequence uint64 + suppressNextCaretPath string + selectionCaretID atspi.ObjectID + selectionCaretSequence uint64 + selectionCaretAt time.Time + autocompleteTyping autocompleteEcho + autocompleteAvailable autocompleteAvailability + typingPath string + typingText string + selection textSelection + documentSelection textSelection + lastClipboardSequence uint64 + lastPasteSequence uint64 + lastPasteID atspi.ObjectID + lastPasteAt time.Time +} + +type eventPresentationState struct { + presentationOptions presentation.Options + pending pendingPresentations + lastSpokenPath string + lastSpokenText string + lastSound string + lastLiveText string + lastLiveAt time.Time + comboHistory comboPresentation + // Retain this identity after the history window closes so delayed GTK + // events cannot enter speech history. + historyWindow atspi.ObjectID + lastFileManagerStatus string + lastFileManagerStatusWindow atspi.ObjectID + pendingPromptWindow atspi.ObjectID + pendingPromptTextAnnounced bool + tableDescriptionWindow atspi.ObjectID + tableDescriptionText string + tableDescriptionAt time.Time + lastTable atspi.ObjectID + lastTableCell atspi.ObjectID + lastTableRow int + lastTableColumn int + lastTableSequence uint64 + lastTableAt time.Time + lastTableFullRow bool + lastTableText string + progressBars ProgressBarHistory +} + +func (state *eventLoopState) ownedAutocompletePopup(event atspi.Event) bool { + return event.Source.AutocompletePopup && event.PopupFor.Valid() && + state.current.Autocomplete && state.current.ID() == event.PopupFor && + state.owner.focus == event.PopupFor && state.owner.window.Valid() && + event.Window.Valid() && event.Window != state.owner.window && + event.Window.BusName == state.owner.applicationBus && + event.Source.BusName == state.owner.applicationBus +} + +// clearOwnedFocus retires only the state cleared by the former event-loop +// closure. Session presentation history and chrome load verification survive. +func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker, speech speechEnqueuer) { + state.selection = textSelection{} + state.documentSelection = textSelection{} + state.lastClipboardSequence = 0 + state.lastPasteSequence = 0 + state.lastPasteID = atspi.ObjectID{} + state.lastPasteAt = time.Time{} + state.selectionCaretID = atspi.ObjectID{} + state.brailleSnapshot = atspi.Snapshot{} + if brailleOutput != nil { + brailleOutput.EnqueueFrame(braille.Frame{Cursor: -1}) + } + state.pending.clear() + state.pendingPromptWindow = atspi.ObjectID{} + state.pendingPromptTextAnnounced = false + state.focusedID = atspi.ObjectID{} + state.selectedBrowserTab = atspi.ObjectID{} + state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0 + state.managedContainerID = atspi.ObjectID{} + state.managedDescendantID = atspi.ObjectID{} + state.current = atspi.Snapshot{} + state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0 + state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" + state.lastLiveText, state.lastLiveAt = "", time.Time{} + state.comboHistory = comboPresentation{} + state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", "" + state.lastAutocompletePopupItem = atspi.ObjectID{} + state.autocompleteTyping = autocompleteEcho{} + state.autocompleteAvailable = autocompleteAvailability{} + state.documentLineFocus = atspi.ObjectID{} + state.documentLineRequest = 0 + state.documentLineEvent = atspi.Event{} + state.loadedDocument = atspi.ObjectID{} + state.loadingDocument = atspi.ObjectID{} + if browser != nil { + browser.clear() + } + if objectReview != nil { + objectReview.ClearFocus() + } + speech.Interrupt() +} + +func (state *eventLoopState) syncForeground(foreground foregroundMonitor, logger *slog.Logger, clearOwnedFocus func()) { + if foreground == nil { + return + } + latest := foreground.Foreground() + if latest.Epoch != state.foregroundState.Epoch { + state.owner.clear() + clearOwnedFocus() + logger.Debug("X11 foreground ownership changed", "x_window", latest.Client.Window, + "pid", latest.Client.PID, "known", latest.Client.Known, "epoch", latest.Epoch) + } + state.foregroundState = latest +} diff --git a/internal/app/event_text_selection.go b/internal/app/event_text_selection.go new file mode 100644 index 0000000..c1b200c --- /dev/null +++ b/internal/app/event_text_selection.go @@ -0,0 +1,74 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atspi.Event, activity input.Activity, intent input.Intent) bool { + state := handler.state + logger := handler.logger + speech := handler.speech + if activity.Clipboard == input.PasteAction && activity.Sequence == state.lastPasteSequence && + event.Source.ID() == state.lastPasteID && time.Since(state.lastPasteAt) < time.Second && + (event.Type == atspi.TextInsertedEvent || event.Type == atspi.TextDeletedEvent || + event.Type == atspi.TextSelectionChangedEvent || event.Type == atspi.CaretEvent) { + if event.Type == atspi.CaretEvent { + state.suppressNextCaretPath = "" + } else if event.Type == atspi.TextSelectionChangedEvent { + state.selection.changed(event.Source) + } + logger.Debug("skipped text event following paste confirmation", "type", event.Type) + return true + } + if event.Type == atspi.TextSelectionChangedEvent { + if event.Source.ID() != state.focusedID || !event.Source.Editable || event.Source.ReadOnly || + event.Source.Role == "password text" || event.Source.Autocomplete { + return true + } + fragmentLimit := selectionFragmentLimit + if activity.Selecting && intent == input.LineIntent { + fragmentLimit = lineSelectionFragmentLimit + } + message := state.selection.changedWithLimit(event.Source, fragmentLimit) + if message != "" { + state.pending.caret.clear() + state.selectionCaretID = event.Source.ID() + state.selectionCaretSequence = activity.Sequence + state.selectionCaretAt = time.Now() + if activity.Clipboard != input.CutAction && activity.Clipboard != input.PasteAction { + speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: event.Type}) + } + } + return true + } + if activity.Sequence != 0 && activity.Sequence != state.lastClipboardSequence && + event.Source.ID() == state.focusedID && event.Source.Editable && !event.Source.ReadOnly && + !event.Source.Autocomplete && event.Source.Role != "password text" { + message := "" + if event.Type == atspi.TextDeletedEvent && activity.Clipboard == input.CutAction { + message = "cut" + } else if event.Type == atspi.TextInsertedEvent && activity.Clipboard == input.PasteAction { + message = "pasted from clipboard" + } + if message != "" { + state.lastClipboardSequence = activity.Sequence + if activity.Clipboard == input.PasteAction { + state.lastPasteSequence = activity.Sequence + state.lastPasteID = event.Source.ID() + state.lastPasteAt = time.Now() + state.pending.caret.clear() + } + speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "clipboard"}) + return true + } + } + return false +} diff --git a/internal/app/event_tooltip.go b/internal/app/event_tooltip.go new file mode 100644 index 0000000..6039be8 --- /dev/null +++ b/internal/app/event_tooltip.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +// A tooltip is transient output. It must not take keyboard focus or replace +// the current review position when the provider shows its popup window. +func (handler *accessibilityEventHandler) handleTooltip(event atspi.Event) bool { + if event.Type != atspi.ShowingStateEvent || event.Source.Role != "tool tip" { + return false + } + if event.Detail1 == 0 || handler.live == nil || !handler.live.Load().Reading.PresentTooltips { + return true + } + options := presentationSettingsFromConfig(handler.live.Load(), true) + spoken := presentation.FocusSpeechWithOptions(event.Source, options) + if text := strings.TrimSpace(spoken.Text); text != "" && text != "tool tip" { + handler.speech.Interrupt() + handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: "system", Cause: "tooltip"}) + } + return true +} diff --git a/internal/app/event_tooltip_test.go b/internal/app/event_tooltip_test.go new file mode 100644 index 0000000..a11701c --- /dev/null +++ b/internal/app/event_tooltip_test.go @@ -0,0 +1,89 @@ +// 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" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +func TestTooltipPreferenceGatesTransientSpeechWithoutMovingFocus(t *testing.T) { + settings := config.DefaultSettings() + live := &liveSettings{} + live.Store(settings) + queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2), interrupt: make(chan struct{}, 2)} + state := &eventLoopState{} + state.focusedID = atspi.ObjectID{BusName: ":1.42", Path: "/button"} + handler := accessibilityEventHandler{state: state, live: live, speech: queue} + event := atspi.NewEvent(atspi.ShowingStateEvent, atspi.Snapshot{Role: "tool tip", Name: "Save document"}) + event.Detail1 = 1 + if !handler.handleTooltip(event) || len(queue.requests) != 0 { + t.Fatal("disabled tooltip was presented") + } + settings.Reading.PresentTooltips = true + live.Store(settings) + if !handler.handleTooltip(event) { + t.Fatal("showing tooltip was not handled") + } + request := <-queue.requests + if request.Text != "Save document, tool tip" || request.Cause != "tooltip" || state.focusedID.Path != "/button" { + t.Fatalf("tooltip presentation = %+v; focus = %+v", request, state.focusedID) + } + event.Detail1 = 0 + if !handler.handleTooltip(event) || len(queue.requests) != 0 { + t.Fatal("hidden tooltip was presented") + } +} + +func TestOwnedTooltipPopupReachesSpeechAndUnownedPopupDoesNot(t *testing.T) { + adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 8)}, changes: make(chan struct{}, 1)} + foreground := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} + adapter.state.Store(&foreground) + settings := config.DefaultSettings() + settings.Reading.PresentTooltips = true + live := &liveSettings{} + live.Store(settings) + queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8), interrupt: make(chan struct{}, 8)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _ = processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live) + }() + defer func() { cancel(); <-done }() + focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/button") + focus.Source.Role, focus.Source.Name, focus.Source.Focused = "push button", "Save", true + focus.Detail1, focus.Foreground = 1, foreground + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Save, push button") + tooltip := ownerEvent(atspi.ShowingStateEvent, ":1.42", "/popup", "/tooltip") + tooltip.Source.Role, tooltip.Source.Name = "tool tip", "Save document" + tooltip.Detail1, tooltip.Foreground, tooltip.ForegroundMismatch = 1, foreground, true + adapter.events <- tooltip + expectActivationSpeech(t, queue.spoken, "Save document, tool tip") + settings.Reading.PresentTooltips = false + live.Store(settings) + adapter.events <- tooltip + select { + case got := <-queue.spoken: + t.Fatalf("disabled tooltip spoke %q", got) + case <-time.After(100 * time.Millisecond): + } + settings.Reading.PresentTooltips = true + live.Store(settings) + tooltip.Source.BusName = ":1.43" + adapter.events <- tooltip + select { + case got := <-queue.spoken: + t.Fatalf("unowned tooltip spoke %q", got) + case <-time.After(100 * time.Millisecond): + } +} diff --git a/internal/app/flat_review.go b/internal/app/flat_review.go new file mode 100644 index 0000000..f8f2d73 --- /dev/null +++ b/internal/app/flat_review.go @@ -0,0 +1,105 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func flatReviewCommand(command input.Command) (review.FlatUnit, int) { + switch command { + case input.ReviewWindowStart: + return review.WindowStart, 0 + case input.ReviewWindowEnd: + return review.WindowEnd, 0 + case input.ReviewPreviousLine: + return review.Line, -1 + case input.ReviewCurrentLine: + return review.Line, 0 + case input.ReviewNextLine: + return review.Line, 1 + case input.ReviewPreviousWord: + return review.Word, -1 + case input.ReviewCurrentWord, input.ReviewSpellWord, input.ReviewPhoneticWord: + return review.Word, 0 + case input.ReviewNextWord: + return review.Word, 1 + case input.ReviewPreviousCharacter: + return review.Character, -1 + case input.ReviewCurrentCharacter, input.ReviewPhoneticCharacter: + return review.Character, 0 + case input.ReviewNextCharacter: + return review.Character, 1 + case input.ReviewWordAbove: + return review.WordAbove, 0 + case input.ReviewWordBelow: + return review.WordBelow, 0 + case input.ReviewLineEnd: + return review.LineEnd, 0 + default: + return 0, 0 + } +} + +func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result review.Result) { + text := "" + if result.Err != nil { + logger.Warn("flat review failed", "error", result.Err) + switch { + case errors.Is(result.Err, review.ErrLimitedReview): + text = "This window exceeds the current flat review text limit." + case errors.Is(result.Err, review.ErrNoReviewContent): + text = "No visible content." + default: + text = "Could not find current location." + } + } else if !result.Moved && (result.FlatStep != 0 || result.FlatUnit == review.WordAbove || result.FlatUnit == review.WordBelow) { + // Python's sequential review stops silently at window boundaries. + return + } else { + text = presentation.FlatReviewText(result.FlatPosition.Text, result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd) + } + speech.Interrupt() + request := speechRequest{Text: text, Role: "text", Cause: "flat-review", Character: result.Err == nil && (result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)} + if result.Err == nil && result.FlatUnit == review.Line { + request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text} + } + if result.Err == nil { + set_review_spelling(&request, result.FlatPosition.Text, result.FlatSpeech) + } + speech.Enqueue(request) + if logger.Enabled(context.Background(), slog.LevelDebug) { + logger.Debug("queued flat review presentation", + "speech_text", request.Text, "spelling", request.Spelling, "speech_style", result.FlatSpeech, + "unit", result.FlatUnit, + "moved", result.Moved, + "bus", result.FlatPosition.Object.BusName, + "object_path", result.FlatPosition.Object.Path, + "offset", result.FlatPosition.Offset, + "has_offset", result.FlatPosition.HasOffset, + "capture_duration", result.CaptureDuration, + "capture_reason", result.CaptureReason, + ) + } +} diff --git a/internal/app/flat_review_test.go b/internal/app/flat_review_test.go new file mode 100644 index 0000000..f04998e --- /dev/null +++ b/internal/app/flat_review_test.go @@ -0,0 +1,196 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestProcessEventsPresentsFlatReviewThroughSpeechQueue(t *testing.T) { + id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) } + snapshot := func(path, name, role string) atspi.Snapshot { + s := atspi.NewSnapshot(path, name, role, "", "") + s.BusName = ":1.42" + return s + } + root := snapshot("/window", "Window", "frame") + field := snapshot("/field", "Review field", "text") + geometry := atspi.Geometry{Bounds: atspi.Rectangle{Width: 200, Height: 50}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + tree, err := atspi.NewAccessibleTree(id("/window"), []atspi.TreeNode{ + {Snapshot: root, Geometry: geometry, Children: []atspi.ObjectID{id("/field")}}, + {Snapshot: field, Geometry: geometry, Text: atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: 6, Text: "a 猫!oo", Bounds: geometry.Bounds}}}}, + }) + if err != nil { + t.Fatal(err) + } + adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree} + worker, err := review.NewWorker(adapter, time.Second) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + speech := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)} + completed := make(chan error, 1) + go func() { + completed <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0) + }() + expect := func(want string) { + t.Helper() + select { + case got := <-speech.spoken: + if got != want { + t.Fatalf("speech=%q want=%q", got, want) + } + case <-time.After(time.Second): + t.Fatal("speech missing") + } + } + adapter.events <- atspi.NewEvent(atspi.FocusEvent, field) + expect("Review field, text") + worker.ReadFlat(review.Line, 0) + expect("a 猫!oo") + worker.ReadFlat(review.Character, 1) + expect("space") + worker.ReadFlat(review.Character, 1) + expect("猫") + worker.ReadFlat(review.Word, 0) + expect("猫!oo") + worker.ReadFlat(review.Character, 1) + expect("!") + worker.ReadFlat(review.Character, 1) + expect("o") + worker.ReadFlat(review.Character, 1) + expect("o") + cancel() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestFlatReviewResultUsesNativeFocusAndRejectsOldPage(t *testing.T) { + worker, err := review.NewWorker(&fakeAdapter{}, time.Second) + if err != nil { + t.Fatal(err) + } + document := atspi.NewObjectID(":1.42", "/document") + worker.SyncFocus(document) + result := review.Result{FlatUnit: review.Line, FlatFocus: document} + // A browse caret can be on a child while native focus remains here. + if !flatReviewResultCurrent(worker, result) { + t.Fatal("flat result from native document focus was discarded") + } + worker.ResetDocument(document) + if flatReviewResultCurrent(worker, result) { + t.Fatal("flat result from the previous page was accepted") + } + result.DocumentEpoch = 1 + worker.SyncFocus(atspi.NewObjectID(":1.42", "/address")) + if flatReviewResultCurrent(worker, result) { + t.Fatal("flat result after focus left the document was accepted") + } +} + +func TestFlatReviewErrorsAndSilentBoundary(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 2)} + logger := slog.New(slog.DiscardHandler) + for _, result := range []review.Result{ + {FlatUnit: review.Line, FlatStep: 1}, + {FlatUnit: review.WordAbove}, + {FlatUnit: review.WordBelow}, + } { + presentFlatReviewResult(logger, queue, result) + select { + case <-queue.spoken: + t.Fatalf("boundary spoke for unit %d", result.FlatUnit) + default: + } + } + presentFlatReviewResult(logger, queue, review.Result{FlatUnit: review.Line, Err: review.ErrLimitedReview}) + if got := <-queue.spoken; got != "This window exceeds the current flat review text limit." { + t.Fatal(got) + } +} + +func TestFlatReviewWindowCommands(t *testing.T) { + if unit, step := flatReviewCommand(input.ReviewWindowStart); unit != review.WindowStart || step != 0 { + t.Fatalf("start = %d, %d", unit, step) + } + if unit, step := flatReviewCommand(input.ReviewWindowEnd); unit != review.WindowEnd || step != 0 { + t.Fatalf("end = %d, %d", unit, step) + } + if unit, step := flatReviewCommand(input.ReviewWordAbove); unit != review.WordAbove || step != 0 { + t.Fatalf("above = %d, %d", unit, step) + } + if unit, step := flatReviewCommand(input.ReviewWordBelow); unit != review.WordBelow || step != 0 { + t.Fatalf("below = %d, %d", unit, step) + } + if unit, step := flatReviewCommand(input.ReviewLineEnd); unit != review.LineEnd || step != 0 { + t.Fatalf("line end = %d, %d", unit, step) + } +} + +func TestFlatReviewBlankAndWhitespaceSpeech(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 1)} + logger := slog.New(slog.DiscardHandler) + for _, tc := range []struct { + unit review.FlatUnit + text, want string + }{ + {review.Line, "\n", "blank"}, + {review.Character, "\n", "blank"}, + {review.WindowEnd, "\n", "blank"}, + {review.Line, " ", "white space"}, + {review.Word, "\t", "white space"}, + {review.Character, " ", "space"}, + {review.LineEnd, "\n", "blank"}, + } { + presentFlatReviewResult(logger, queue, review.Result{FlatUnit: tc.unit, Moved: true, FlatPosition: review.FlatPosition{Text: tc.text}}) + if got := <-queue.spoken; got != tc.want { + t.Fatalf("unit=%v text=%q: got=%q want=%q", tc.unit, tc.text, got, tc.want) + } + } +} + +func TestFlatReviewCharacterVoice(t *testing.T) { + for _, unit := range []review.FlatUnit{review.Character, review.LineEnd, review.Word, review.Line} { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} + presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, review.Result{Moved: true, FlatUnit: unit}) + request := <-queue.requests + want := unit == review.Character || unit == review.LineEnd + if request.Character != want { + t.Fatalf("unit %v: character = %v", unit, request.Character) + } + } +} diff --git a/internal/app/focus_gate.go b/internal/app/focus_gate.go new file mode 100644 index 0000000..9bacfb3 --- /dev/null +++ b/internal/app/focus_gate.go @@ -0,0 +1,163 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "sync" + "sync/atomic" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type commandGrabController interface { + SetCommandGrabs(bool) +} + +type focusGate struct { + tentacleOutput *tentacleOutput + + ocr *ocrSession + mutex sync.Mutex + speech speechEnqueuer + suspended atomic.Bool + secure atomic.Bool + lockChanges chan struct{} + commands commandGrabController + internalUI interface{ InternalUIActive() bool } + braille *brailleWorker +} + +func newFocusGate(commands commandListener) *focusGate { + gate := &focusGate{lockChanges: make(chan struct{}, 1)} + if controller, ok := commands.(commandGrabController); ok { + gate.commands = controller + } + gate.internalUI, _ = commands.(interface{ InternalUIActive() bool }) + return gate +} + +func (gate *focusGate) SetSuspended(suspended bool) bool { + if gate == nil { + return false + } + if suspended { + gate.ocr.Retire() + } + gate.mutex.Lock() + defer gate.mutex.Unlock() + if !gate.suspended.CompareAndSwap(!suspended, suspended) { + return false + } + gate.retireTentacleOutput() + if suspended { + gate.tentacleOutput.Stop() + } + if suspended && !gate.secure.Load() && gate.speech != nil { + gate.speech.Interrupt() + } + if gate.commands != nil { + gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load()) + } + return true +} + +func (gate *focusGate) InternalUIActive() bool { + return gate != nil && gate.internalUI != nil && gate.internalUI.InternalUIActive() +} + +func (gate *focusGate) Suspended() bool { + return gate != nil && gate.suspended.Load() +} + +func (gate *focusGate) retireTentacleOutput() { + if gate != nil { + gate.tentacleOutput.Retire() + } +} + +func (gate *focusGate) dismiss_confirmation() { + if gate != nil && gate.braille != nil { + gate.braille.DismissConfirmation() + } +} + +type gatedSpeech struct { + speech speechEnqueuer + gate *focusGate +} + +func (speaker gatedSpeech) Enqueue(request speechRequest) { + if speaker.gate != nil { + speaker.gate.mutex.Lock() + defer speaker.gate.mutex.Unlock() + } + if speaker.gate.InternalUIActive() && request.Cause != "internal-ui" { + return + } + if speaker.gate.Locked() || speaker.gate.Suspended() && !request.BypassFocusGate { + return + } + speaker.speech.Enqueue(request) +} + +func (speaker gatedSpeech) Interrupt() { + if speaker.gate.InternalUIActive() { + return + } + speaker.InterruptInternalUI() +} + +func (speaker gatedSpeech) InterruptInternalUI() { + if speaker.gate != nil { + speaker.gate.mutex.Lock() + defer speaker.gate.mutex.Unlock() + } + if !speaker.gate.Locked() && !speaker.gate.Suspended() { + speaker.speech.Interrupt() + } +} + +func (speaker gatedSpeech) InterruptForUser() { + if speaker.gate.Locked() { + return + } + speaker.gate.dismiss_confirmation() + if interrupt, ok := speaker.speech.(interface{ InterruptForUser() }); ok { + interrupt.InterruptForUser() + } else { + speaker.Interrupt() + } +} + +func (speaker gatedSpeech) InterruptForInput(event input.Event) bool { + if speaker.gate.InternalUIActive() { + return false + } + if speaker.gate.Locked() { + return false + } + if speech, ok := speaker.speech.(interface{ InterruptForInput(input.Event) bool }); ok { + interrupted := speech.InterruptForInput(event) + if interrupted { + speaker.gate.dismiss_confirmation() + } + return interrupted + } + speaker.InterruptForUser() + return true +} diff --git a/internal/app/foreground_test.go b/internal/app/foreground_test.go new file mode 100644 index 0000000..37996a3 --- /dev/null +++ b/internal/app/foreground_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "log/slog" + "sync/atomic" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +type foregroundAdapter struct { + fakeAdapter + state atomic.Pointer[atspi.ForegroundState] + changes chan struct{} +} + +func TestForegroundAutocompletePopupRequiresOwnedEntry(t *testing.T) { + a := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 16)}, changes: make(chan struct{}, 1)} + state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} + a.state.Store(&state) + queue := &fakeSpeechQueue{spoken: make(chan string, 16)} + keys := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, a, nil, nil, 0, nil, keys, nil, nil) + }() + defer func() { cancel(); <-done }() + + entry := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/browser", "/address") + entry.Source.Role = "entry" + entry.Source.Autocomplete = true + entry.Source.Editable = true + entry.Source.Focused = true + entry.Detail1 = 1 + entry.Foreground = state + a.events <- entry + expectActivationSpeech(t, queue.spoken, "autocomplete") + + keys.Observe(input.NewKeyEvent(0, true, "Down")) + popup := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/popup", "/item") + popup.Source.Role = "list item" + popup.Source.Name = "Suggestion" + popup.Source.Selected = true + popup.Source.AutocompletePopup = true + popup.Source.Focused = true + popup.Detail1 = 1 + popup.PopupFor = entry.Source.ID() + popup.Foreground = state + popup.ForegroundMismatch = true + a.events <- popup + expectActivationSpeech(t, queue.spoken, "Suggestion, list item, selected") + + // A different relation target or bus cannot borrow the browser's window. + for _, bad := range []atspi.Event{ + func() atspi.Event { e := popup; e.PopupFor = atspi.NewObjectID(":1.40", "/other-entry"); return e }(), + func() atspi.Event { e := popup; e.Source.BusName = ":1.41"; return e }(), + } { + bad.Source.Name = "Background suggestion" + a.events <- bad + } + // The popup must not replace the owned address entry or prevent typing. + insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "z") + insertion.Window = entry.Window + insertion.Foreground = state + keys.Observe(input.NewKeyEvent(0, true, "z")) + a.events <- insertion + expectActivationSpeech(t, queue.spoken, "z") + + other := ownerEvent(atspi.FocusedStateEvent, ":1.41", "/other-window", "/button") + other.Source.Role = "push button" + other.Source.Name = "Other window" + other.Source.Focused = true + other.Detail1 = 1 + other.Foreground = state + a.events <- other + expectActivationSpeech(t, queue.spoken, "Other window, push button") + popup.Source.Name = "Inactive suggestion" + a.events <- popup + other.Source.Name = "Still foreground" + a.events <- other + expectActivationSpeech(t, queue.spoken, "Still foreground, push button") +} + +func TestAutocompletePopupNameChangesDoNotCue(t *testing.T) { + adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 16)}, changes: make(chan struct{}, 1)} + state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} + adapter.state.Store(&state) + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)} + keys := &input.IntentTracker{} + options := presentation.DefaultOptions() + options.EnableSound = true + options.PlaySoundForRole = true + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, keys, nil, nil, options) + }() + defer func() { cancel(); <-done }() + + entry := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/browser", "/address") + entry.Source.Role, entry.Source.Autocomplete, entry.Source.Editable, entry.Source.Focused = "entry", true, true, true + entry.Detail1, entry.Foreground = 1, state + adapter.events <- entry + if got := <-queue.requests; got.Text != "autocomplete" { + t.Fatalf("focus request = %#v", got) + } + keys.Observe(input.NewKeyEvent(0, true, "g")) + insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "g") + insertion.Window, insertion.Foreground = entry.Window, state + adapter.events <- insertion + if got := <-queue.requests; got.Text != "g" || len(got.Sound) != 0 { + t.Fatalf("typing request = %#v", got) + } + popup := ownerEvent(atspi.AccessibleNameEvent, ":1.40", "/popup", "/item") + popup.Source.Role, popup.Source.AutocompletePopup = "list item", true + popup.PopupFor, popup.Foreground, popup.ForegroundMismatch = entry.Source.ID(), state, true + for _, name := range []string{"First suggestion", "Second suggestion", "Third suggestion"} { + popup.Source.Name = name + adapter.events <- popup + } + select { + case got := <-queue.requests: + t.Fatalf("popup name update produced output: %#v", got) + case <-time.After(300 * time.Millisecond): + } + keys.Observe(input.NewKeyEvent(0, true, "Down")) + selected := popup + selected.Type = atspi.FocusedStateEvent + selected.Detail1 = 1 + selected.Source.Selected = true + adapter.events <- selected + select { + case got := <-queue.requests: + if got.Text != "Third suggestion, list item, selected" { + t.Fatalf("selected popup item = %#v", got) + } + case <-time.After(time.Second): + t.Fatal("missing selected popup item") + } +} + +func (a *foregroundAdapter) Foreground() atspi.ForegroundState { return *a.state.Load() } +func (a *foregroundAdapter) ForegroundChanges() <-chan struct{} { return a.changes } + +func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) { + for _, window := range []uint64{0, 202} { + t.Run(map[uint64]string{0: "empty workspace", 202: "other window"}[window], func(t *testing.T) { + a := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 16)}, changes: make(chan struct{}, 1)} + state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} + a.state.Store(&state) + queue := &fakeSpeechQueue{spoken: make(chan string, 16)} + where := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, a, where, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + event := ownerEvent(atspi.WindowActivatedEvent, ":1.5", "/window", "/button") + event.Source.Name = "Current" + event.Source.Role = "push button" + event.Foreground = state + a.events <- event + expectActivationSpeech(t, queue.spoken, "Current, push button") + next := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: window}, Epoch: 2} + a.state.Store(&next) + // No wakeup is required: event/result consumers must check the latest + // atomic epoch even if the channel notification has not been selected. + event.Source.Name = "Stale" + a.events <- event + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "Could not find current location.") + event.Foreground = next + event.ForegroundMismatch = true + a.events <- event + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "Could not find current location.") + if window != 0 { + event.ForegroundMismatch = false + event.Source.Name = "New" + a.events <- event + expectActivationSpeech(t, queue.spoken, "New, push button") + deactivated := event + deactivated.Type = atspi.WindowDeactivatedEvent + a.events <- deactivated + where <- struct{}{} + expectActivationSpeech(t, queue.spoken, "New, push button") + } + }) + } +} diff --git a/internal/app/gui_table_presentation.go b/internal/app/gui_table_presentation.go new file mode 100644 index 0000000..8f669e2 --- /dev/null +++ b/internal/app/gui_table_presentation.go @@ -0,0 +1,99 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +type guiTablePresentation struct { + options presentation.Options + table atspi.ObjectID + sequence uint64 + newDescription bool + skip bool +} + +func (state *eventLoopState) prepareGUITable(event atspi.Event, sequence uint64) guiTablePresentation { + if event.Type == atspi.WindowActivatedEvent { + state.tableDescriptionWindow = atspi.ObjectID{} + state.tableDescriptionText = "" + state.lastTable = atspi.ObjectID{} + state.lastTableCell = atspi.ObjectID{} + } else if isFocusGainEvent(event) && event.Source.Role != "table" && event.Source.Role != "table cell" { + state.lastTable = atspi.ObjectID{} + state.lastTableCell = atspi.ObjectID{} + } + result := guiTablePresentation{ + options: state.presentationOptions, + table: atspi.NewObjectID(event.Source.BusName, event.Source.TablePath), + sequence: sequence, + } + result.newDescription = state.presentationOptions.SpeakDescription && event.Window.Valid() && + (event.Source.Role == "table" || event.Source.Role == "table cell") && + event.WindowDescription != "" && event.WindowDescription != event.Source.Description && + (state.tableDescriptionWindow != event.Window || state.tableDescriptionText != event.WindowDescription) + sameRow := event.Source.Role == "table cell" && + event.Source.TablePositionKnown && result.table.Valid() && result.table == state.lastTable && + event.Source.TableRowIndex == state.lastTableRow + if sameRow { + unchangedRow := presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == state.lastTableText + if unchangedRow || event.Source.ID() != state.lastTableCell { + result.options.ReadFullRowInGUITable = false + if result.options.Reading != nil { + reading := *result.options.Reading + reading.ReadFullRowInDocumentTable, reading.ReadFullRowInSpreadSheet = false, false + result.options.Reading = &reading + } + } + sameAction := sequence != 0 && sequence == state.lastTableSequence || + sequence == 0 && time.Since(state.lastTableAt) < 75*time.Millisecond + result.skip = sameAction && !result.newDescription && unchangedRow && + (state.lastTableFullRow || event.Source.TableColumnIndex == state.lastTableColumn) + } + return result +} + +func (state *eventLoopState) decorateGUITable(event atspi.Event, spoken presentation.Speech, result guiTablePresentation) (presentation.Speech, bool) { + if event.Source.Role == "table cell" && event.Source.GUITable && result.table.Valid() && + result.table != state.lastTable && event.Source.TableName != "" && + (isFocusGainEvent(event) || isManagedDescendantEvent(event.Type)) && + event.Type != atspi.WindowActivatedEvent { + spoken = spoken.Prepend(event.Source.TableName, ", ") + } + appendFocus := isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent && + state.tableDescriptionWindow.Valid() && event.Window == state.tableDescriptionWindow && + time.Since(state.tableDescriptionAt) < 300*time.Millisecond + if result.newDescription { + if event.Type != atspi.WindowActivatedEvent { + spoken = spoken.Append(event.WindowDescription, ", ") + } + state.tableDescriptionWindow = event.Window + state.tableDescriptionText = event.WindowDescription + state.tableDescriptionAt = time.Now() + } + return spoken, appendFocus +} + +func (state *eventLoopState) rememberGUITable(event atspi.Event, result guiTablePresentation) { + if event.Source.Role == "table cell" && event.Source.TablePositionKnown && result.table.Valid() { + state.lastTable = result.table + state.lastTableCell = event.Source.ID() + state.lastTableText = presentation.FocusTextWithOptions(event.Source, state.presentationOptions) + state.lastTableRow = event.Source.TableRowIndex + state.lastTableColumn = event.Source.TableColumnIndex + state.lastTableSequence = result.sequence + state.lastTableAt = time.Now() + state.lastTableFullRow = presentation.FullRowEnabled(event.Source, result.options) && event.Source.TableRowText != "" + } else if event.Source.Role == "table" && isFocusGainEvent(event) { + state.lastTable = event.Source.ID() + state.lastTableCell = atspi.ObjectID{} + state.lastTableRow = -1 + } +} diff --git a/internal/app/gui_table_test.go b/internal/app/gui_table_test.go new file mode 100644 index 0000000..23414fb --- /dev/null +++ b/internal/app/gui_table_test.go @@ -0,0 +1,95 @@ +// 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 TestGUITableNormalizedFocusRetainsOwnerAndTitle(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} + inputs := &sequencedInputActivity{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, + nil, nil, 0, nil, inputs, nil, nil) + close(completed) + }() + window := atspi.NewObjectID(":1.22", "/preferences") + cell_event := func(path, name, rowText string, row, column int, eventType string) atspi.Event { + cell := atspi.NewSnapshot(path, name, "table cell", "", "") + cell.BusName = window.BusName + cell.TablePath, cell.TableName = "/preferences/reading", "Reading settings" + cell.TableRowIndex, cell.TableColumnIndex = row, column + cell.TablePositionKnown, cell.GUITable = true, true + cell.TableRowText = rowText + event := atspi.NewManagedDescendantEvent(eventType, cell.TablePath, cell) + event.Window = window + if eventType == atspi.FocusedStateEvent { + event.Detail1 = 1 + } + return event + } + expect_speech := func(event atspi.Event, sequence uint64, want string) { + t.Helper() + inputs.set(sequence) + adapter.events <- event + select { + case request := <-speech.requests: + if request.Text != want { + t.Fatalf("%s: got %q, want %q", event.Type, request.Text, want) + } + case <-time.After(time.Second): + t.Fatalf("%s produced no speech", event.Type) + } + } + // The adapter resolves the table's initial focus to a selected cell. GTK + // need not send another focused-state event as Up/Down changes rows. + first := cell_event("/reading/0/state", "unchecked", "unchecked, Only speak displayed text", 0, 0, atspi.FocusedStateEvent) + expect_speech(first, 1, "Reading settings, unchecked, Only speak displayed text") + second := cell_event("/reading/1/state", "checked", "checked, Break speech into chunks between pauses", 1, 0, atspi.SelectionChangedEvent) + expect_speech(second, 2, "checked, Break speech into chunks between pauses") + duplicate := second + duplicate.Type = atspi.ActiveDescendantEvent + adapter.events <- duplicate + right := cell_event("/reading/1/label", "Break speech into chunks between pauses", second.Source.TableRowText, 1, 1, atspi.ActiveDescendantEvent) + expect_speech(right, 3, "Break speech into chunks between pauses") + left := second + left.Type = atspi.ActiveDescendantEvent + expect_speech(left, 4, "checked") + up := first + up.Type = atspi.SelectionChangedEvent + expect_speech(up, 5, "unchecked, Only speak displayed text") + // Background tables and another bus cannot inherit the focused table. + other := second + other.OriginPath, other.Source.TablePath = "/unrelated", "/unrelated" + other.Source.Path = "/unrelated/cell" + adapter.events <- other + other.OriginPath, other.Source.TablePath = first.OriginPath, first.Source.TablePath + other.Source.BusName = ":1.99" + adapter.events <- other + expect_speech(second, 6, "checked, Break speech into chunks between pauses") + button := atspi.NewSnapshot("/preferences/apply", "Apply", "button", "", "") + button.BusName = window.BusName + focus := atspi.NewEvent(atspi.FocusEvent, button) + focus.Window = window + expect_speech(focus, 7, "Apply, button") + adapter.events <- up // A table selection after leaving the table is noise. + // Returning through a cell focus event establishes ownership and speaks + // the table name again, without changing the user's row-reading option. + first.OriginPath = first.Source.Path + expect_speech(first, 8, "Reading settings, unchecked, Only speak displayed text") + expect_speech(second, 9, "checked, Break speech into chunks between pauses") + cancel() + <-completed +} diff --git a/internal/app/i38lock_feedback_native_test.go b/internal/app/i38lock_feedback_native_test.go new file mode 100644 index 0000000..e1ad895 --- /dev/null +++ b/internal/app/i38lock_feedback_native_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/remote" +) + +type lockerFeedbackSpeaker struct{ spoken chan string } + +func (speaker *lockerFeedbackSpeaker) Connect(context.Context) error { return nil } +func (speaker *lockerFeedbackSpeaker) Close(context.Context) error { return nil } +func (speaker *lockerFeedbackSpeaker) Cancel(context.Context) error { return nil } +func (speaker *lockerFeedbackSpeaker) Speak(_ context.Context, text string) error { + speaker.spoken <- text + return nil +} + +type lockerFeedbackPresenter struct { + remoteSpeech + interrupts atomic.Int32 + presented chan struct{} +} + +func (presenter *lockerFeedbackPresenter) PresentMessage(message string) bool { + accepted := presenter.remoteSpeech.PresentMessage(message) + presenter.presented <- struct{}{} + return accepted +} + +func (presenter *lockerFeedbackPresenter) CancelSpeech() bool { + presenter.interrupts.Add(1) + return presenter.remoteSpeech.CancelSpeech() +} + +// Compile the actual locker feedback implementation without X grabs or PAM. +// This catches mismatches in its bus discovery and complete D-Bus call sequence. +func TestNativeI38lockFeedback(t *testing.T) { + sourceDirectory := os.Getenv("CTHULHU_I38LOCK_SOURCE") + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" || sourceDirectory == "" { + t.Skip("requires private session bus and CTHULHU_I38LOCK_SOURCE checkout") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + directory := t.TempDir() + harness := filepath.Join(directory, "feedback.c") + source := `#include +#include "accessibility_feedback.h" +int main(void) { + if (!accessibility_feedback_start(-1)) return 1; + accessibility_feedback_notify(ACCESSIBILITY_SCREEN_LOCKED); + puts("ready"); + fflush(stdout); + int event; + while (scanf("%d", &event) == 1) { + accessibility_feedback_notify((accessibility_feedback_t)event); + } + return 0; +} +` + if err := os.WriteFile(harness, []byte(source), 0600); err != nil { + t.Fatal(err) + } + flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "gio-2.0").Output() + if err != nil { + t.Fatal(err) + } + executable := filepath.Join(directory, "i38lock") + arguments := []string{"-o", executable, harness, filepath.Join(sourceDirectory, "accessibility_feedback.c"), "-I" + filepath.Join(sourceDirectory, "include")} + arguments = append(arguments, strings.Fields(string(flags))...) + if output, err := exec.CommandContext(ctx, "cc", arguments...).CombinedOutput(); err != nil { + t.Fatalf("compile actual feedback: %v\n%s", err, output) + } + logger := slog.New(slog.DiscardHandler) + speaker := &lockerFeedbackSpeaker{spoken: make(chan string, 32)} + worker := newSpeechWorker(logger, speaker) + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + display := &brailleWorker{cancel: func() {}, connected: true, wake: make(chan struct{}, 1)} + gate := newFocusGate(nil) + states := make(chan bool, 8) + monitor := &screenLockMonitor{logger: logger, setActive: func(active bool) { + gate.SetLocked(active, nil, nil, nil, worker, display) + states <- active + }} + if err := monitor.Start(ctx); err != nil { + t.Fatal(err) + } + defer monitor.Stop(context.Background()) + presenter := &lockerFeedbackPresenter{remoteSpeech: remoteSpeech{speech: worker, braille: lockFeedbackBraille{display}}, presented: make(chan struct{}, 32)} + service := remote.New(logger, presenter) + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + defer service.Stop(context.Background()) + process := exec.CommandContext(ctx, executable) + stdin, err := process.StdinPipe() + if err != nil { + t.Fatal(err) + } + defer stdin.Close() + stdout, err := process.StdoutPipe() + if err != nil { + t.Fatal(err) + } + process.Stderr = os.Stderr + if err := process.Start(); err != nil { + t.Fatal(err) + } + defer process.Process.Kill() + ready := make(chan string, 1) + go func() { line, _ := bufio.NewReader(stdout).ReadString('\n'); ready <- line }() + select { + case line := <-ready: + if line != "ready\n" { + t.Fatalf("locker ready: %q", line) + } + case <-ctx.Done(): + t.Fatal("locker startup timed out") + } + expectState := func(want bool) { + t.Helper() + select { + case got := <-states: + if got != want { + t.Fatalf("secure state=%v want=%v", got, want) + } + case <-ctx.Done(): + t.Fatal("missing lock transition") + } + } + expectState(true) + // Local output is blocked. Locker feedback travels through self-voice and + // does not depend on enabling a local key-echo presenter. + worker.Enqueue(speechRequest{Text: "Desktop notification", Cause: "notification"}) + for index, message := range []string{"Screen locked. Enter password.", "Enter password.", "star", "backspace star", "backspace blank", "Password cleared.", "Verifying.", "Authenticated.", "Incorrect password. Enter password."} { + if index > 0 { + if _, err := fmt.Fprintln(stdin, index); err != nil { + t.Fatal(err) + } + } + select { + case spoken := <-speaker.spoken: + if spoken != message { + t.Fatalf("feedback %d = %q want=%q", index, spoken, message) + } + case <-ctx.Done(): + t.Fatalf("missing feedback %d: %q", index, message) + } + select { + case <-presenter.presented: + case <-ctx.Done(): + t.Fatal("presentation did not complete") + } + if got := presenter.interrupts.Load(); got != int32(index+1) { + t.Fatalf("feedback %d interrupts=%d", index, got) + } + if got := display.current().Text; got != message { + t.Fatalf("braille feedback=%q want=%q", got, message) + } + } + stdin.Close() + if err := process.Wait(); err != nil { + t.Fatal(err) + } + expectState(false) + worker.Enqueue(speechRequest{Text: "Desktop resumed", Cause: "focus"}) + select { + case spoken := <-speaker.spoken: + if spoken != "Desktop resumed" { + t.Fatalf("unlock speech=%q", spoken) + } + case <-ctx.Done(): + t.Fatal("desktop speech did not resume") + } +} diff --git a/internal/app/indentation.go b/internal/app/indentation.go new file mode 100644 index 0000000..dcdd92b --- /dev/null +++ b/internal/app/indentation.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "fmt" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +type speechLine struct { + Object atspi.ObjectID + Raw string +} +type indentationData struct { + signature string + columns int +} +type indentationHistory map[atspi.ObjectID]indentationData + +func indentation_data(line string) indentationData { + var data indentationData + for _, value := range line { + switch value { + case ' ', '\u00a0': + data.columns++ + value = ' ' + case '\t': + data.columns += 4 - data.columns%4 + default: + return data + } + data.signature += string(value) + } + return data +} + +func (worker *speechWorker) line_text(text string, line *speechLine) string { + if line == nil || worker.reading == nil { + return text + } + data := indentation_data(line.Raw) + previous, known := worker.indentation[line.Object] + if worker.indentation == nil || len(worker.indentation) >= 128 && !known { + worker.indentation = make(indentationHistory) + } + worker.indentation[line.Object] = data + settings := worker.reading() + if !settings.EnableIndentation || settings.OnlySpeakDisplayedText || known && previous.signature == data.signature { + return text + } + mode := settings.IndentationPresentationMode + if (mode == "beeps" || mode == "both") && worker.indentationCue != nil { + frequency := min(2700, 200+data.columns*30) + if data.columns > previous.columns { + frequency += 50 + } else if data.columns < previous.columns { + frequency = max(100, frequency-50) + } + worker.indentationCue(sound.Cue{Name: sound.IndentationCueName(frequency)}) + } + if mode == "beeps" { + return text + } + word := "columns" + if data.columns == 1 { + word = "column" + } + return strings.TrimSuffix(fmt.Sprintf("indentation %d %s, %s", data.columns, word, text), ", ") +} + +func document_speech_line(line atspi.DocumentLine) *speechLine { + if len(line.Contents) == 0 { + return nil + } + content := line.Contents[0] + if content.Snapshot.Role == "password text" { + return nil + } + raw := content.RawText + if raw == "" { + raw = content.Text + } + return &speechLine{Object: content.Snapshot.ID(), Raw: raw} +} diff --git a/internal/app/input_events.go b/internal/app/input_events.go new file mode 100644 index 0000000..572081d --- /dev/null +++ b/internal/app/input_events.go @@ -0,0 +1,123 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func processInputEvents( + ctx context.Context, + logger *slog.Logger, + speech speechEnqueuer, + listener InputListener, + echoSettings input.EchoSettings, + live *liveSettings, + commands commandListener, + ocrSessions ...*ocrSession, +) { + echo := newKeyEchoPolicy(echoSettings) + var lastOverflow uint64 + for { + select { + case <-ctx.Done(): + return + case event, ok := <-listener.Events(): + if !ok { + return + } + if guarded, ok := speech.(gatedSpeech); ok && guarded.gate.Locked() { + echo.Reset() + continue + } + if modal, ok := commands.(interface{ InternalUIEchoActive() bool }); ok && modal.InternalUIEchoActive() { + echo.Reset() + continue + } + if learner, ok := commands.(interface{ LearnModeEchoActive() bool }); ok && learner.LearnModeEchoActive() { + echo.Reset() + continue + } + if overflow := listener.Overflow(); overflow > lastOverflow { + logger.Warn("X11 event queue overflow", "dropped_events", overflow-lastOverflow) + lastOverflow = overflow + echo.Reset() + } + if len(ocrSessions) > 0 { + ocrSessions[0].Input(event) + } + if input.ShouldInterruptSpeech(event) { + if interrupt, ok := speech.(interface{ InterruptForInput(input.Event) bool }); ok { + if !interrupt.InterruptForInput(event) { + continue + } + } else if interrupt, ok := speech.(interface{ InterruptForUser() }); ok { + interrupt.InterruptForUser() + } else { + speech.Interrupt() + } + logger.Debug("queued speech interrupted by key press", "input_timestamp", event.Timestamp, + "input_keycode", event.Keycode, "input_repeat", event.Repeat) + } + custom := false + if live != nil { + settings := live.Load() + echo.settings = inputEchoSettingsFromConfig(settings) + custom = customEcho(settings, "key") + } + if text := echo.Text(event); text != "" { + speech.Enqueue(speechRequest{Text: text, Role: "key", Cause: "key-echo", CustomEcho: custom}) + logger.Debug("queued non-printable key echo", "kind", event.Kind) + } + } + } +} + +type keyEchoPolicy struct { + settings input.EchoSettings + cthulhuModifiers map[string]struct{} + heldCthulhuKeys [256]bool +} + +func newKeyEchoPolicy(settings input.EchoSettings) *keyEchoPolicy { + modifiers := make(map[string]struct{}, len(settings.CthulhuModifierKeys)) + for _, name := range settings.CthulhuModifierKeys { + modifiers[name] = struct{}{} + } + return &keyEchoPolicy{settings: settings, cthulhuModifiers: modifiers} +} + +func (policy *keyEchoPolicy) Text(event input.Event) string { + if _, isCthulhuModifier := policy.cthulhuModifiers[event.Name]; isCthulhuModifier { + policy.heldCthulhuKeys[event.Keycode] = event.Pressed + return "" + } + for _, held := range policy.heldCthulhuKeys { + if held { + return "" + } + } + return input.EchoText(event, policy.settings) +} + +func (policy *keyEchoPolicy) Reset() { + clear(policy.heldCthulhuKeys[:]) +} diff --git a/internal/app/input_events_test.go b/internal/app/input_events_test.go new file mode 100644 index 0000000..0b58073 --- /dev/null +++ b/internal/app/input_events_test.go @@ -0,0 +1,168 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +func TestProcessInputEventsInterruptsSpeechOnKeyPress(t *testing.T) { + speech := &fakeSpeechQueue{ + spoken: make(chan string, 1), + interrupt: make(chan struct{}, 3), + } + listener := &fakeInputListener{ + events: make(chan input.Event, 4), + modifierKeycodes: input.NewModifierKeycodes(37), + } + listener.events <- input.Event{Keycode: 37, Pressed: false} + listener.events <- input.NewKeyEvent(111, true, "Up") + listener.events <- input.NewKeyEvent(111, false, "Up") + listener.events <- input.Event{Keycode: 37, Pressed: true} + close(listener.events) + processInputEvents(context.Background(), slog.New(slog.DiscardHandler), speech, listener, input.EchoSettings{}, nil, nil) + if got := len(speech.interrupt); got != 2 { + t.Fatalf("interrupts = %d, want 2 for Up and Control presses", got) + } +} + +func TestProcessInputEventsEchoesConfiguredNonPrintableKeys(t *testing.T) { + speech := &fakeSpeechQueue{ + spoken: make(chan string, 3), + interrupt: make(chan struct{}, 4), + } + listener := &fakeInputListener{ + events: make(chan input.Event, 5), + modifierKeycodes: input.NewModifierKeycodes(37), + } + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + go func() { + processInputEvents(ctx, slog.New(slog.DiscardHandler), speech, listener, input.EchoSettings{ + EnableKeyEcho: true, EnableModifierKeys: true, EnableFunctionKeys: true, + CthulhuModifierKeys: []string{"Insert", "KP_Insert"}, + }, nil, nil) + close(completed) + }() + + listener.events <- input.NewKeyEvent(37, true, "Control_L") + listener.events <- input.NewKeyEvent(118, true, "Insert") + listener.events <- input.NewKeyEvent(67, true, "F1") + listener.events <- input.NewKeyEvent(118, false, "Insert") + listener.events <- input.NewKeyEvent(68, true, "F2") + + select { + case text := <-speech.spoken: + if text != "left control" { + t.Fatalf("modifier echo = %q", text) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for modifier echo") + } + select { + case text := <-speech.spoken: + if text != "f2" { + t.Fatalf("Cthulhu chord key echo leaked as %q before f2", text) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for key echo after Cthulhu modifier release") + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for input event loop to stop") + } +} + +func TestProcessEventsAppliesPrintableKeyEchoSettings(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + options := presentation.DefaultOptions() + options.EnableAlphabeticKeys = false + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0, options) + close(completed) + }() + + snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "") + snapshot.Editable = true + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "a") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "pasted text") + select { + case text := <-speech.spoken: + if text != "pasted text" { + t.Fatalf("disabled alphabetic key echo spoke %q before multi-character insertion", text) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for multi-character insertion") + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestProcessEventsRetainsPriorLineForSentenceEchoOnNewline(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + options := presentation.DefaultOptions() + options.EnableEchoBySentence = true + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0, options) + close(completed) + }() + + snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "Line without punctuation") + snapshot.Editable = true + snapshot.TextBeforeCaret = "Line without punctuation" + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "n") + blankLine := atspi.NewSnapshot("/accessible/editor", "", "text", "", "") + blankLine.Editable = true + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, blankLine, "\n") + + for _, want := range []string{"n", "Line without punctuation"} { + select { + case text := <-speech.spoken: + if text != want { + t.Fatalf("typing echo = %q, want %q", text, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for typing echo %q", want) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go new file mode 100644 index 0000000..3a66f5c --- /dev/null +++ b/internal/app/internal_ui.go @@ -0,0 +1,388 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "io" + "os/exec" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type internalUIKeys interface { + InternalUISession() uint64 + BeginInternalUI(context.Context) (uint64, error) + EndInternalUI(context.Context, uint64) error +} +type accessibleActionSource interface { + AccessibleActions(context.Context, atspi.ObjectID) (atspi.ActionSet, error) + PerformAccessibleAction(context.Context, atspi.ActionTarget, string) error +} +type internalUIResult struct { + Activated string + Values map[string]interaction.Value + Cancelled bool + Err error +} +type internalUINotice struct { + ctx context.Context + value interaction.Presentation +} +type internalUIRequest struct { + ctx context.Context + session *interaction.Session + result chan internalUIResult + ready chan error +} + +// internalUI owns one modal session on its worker. The bounded event channel +// keeps AT-SPI/clipboard latency off the native input and command consumers. +// Core clients can Open a form and receive its submitted values/cancellation. +type internalUI struct { + keys internalUIKeys + actions accessibleActionSource + interest interface { + ObjectOfInterest(context.Context) (review.Interest, error) + } + speech speechEnqueuer + braille *brailleWorker + gate *focusGate + notices chan internalUINotice + events chan input.CommandEvent + requests chan internalUIRequest + done chan struct{} +} + +func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + session, err := interaction.NewSession(form) + if err != nil { + return nil, err + } + request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)} + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ui.done: + return nil, errors.New("internal UI stopped") + default: + } + select { + case ui.requests <- request: + default: + return nil, errors.New("internal UI busy") + } + select { + case err := <-request.ready: + if err != nil { + return nil, err + } + return request.result, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-ui.done: + return nil, errors.New("internal UI stopped") + } +} +func (ui *internalUI) Notice(ctx context.Context, value tentaclewire.Presentation) { + if value.Speech == "" && value.Braille == "" { + return + } + select { + case ui.notices <- internalUINotice{ctx, interaction.Presentation{Speech: value.Speech, Braille: value.Braille, Cursor: -1}}: + default: + } +} +func (ui *internalUI) Handle(event input.CommandEvent) bool { + if ui == nil || event.Command != input.ShowActions && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss { + return false + } + select { + case ui.events <- event: + default: + return false + } + return true +} + +func (ui *internalUI) present(output interaction.Presentation) { + if ui.gate.Locked() || ui.gate.Suspended() { + return + } + if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok { + speech.InterruptInternalUI() + } else { + ui.speech.Interrupt() + } + if output.Speech != "" { + ui.speech.Enqueue(speechRequest{Text: output.Speech, Role: "system", Cause: "internal-ui"}) + } + if ui.braille != nil && output.Braille != "" { + ui.braille.SetInternalUIFrame(&braille.Frame{Text: output.Braille, Cursor: output.Cursor}) + } +} + +func (ui *internalUI) run(ctx context.Context) { + defer close(ui.done) + var session *interaction.Session + var sessionID uint64 + var target atspi.ActionTarget + var result chan internalUIResult + var requestDone <-chan struct{} + finish := func(reply internalUIResult) error { + var closeErr error + if sessionID != 0 { + closeCtx, cancel := context.WithTimeout(ctx, time.Second) + closeErr = ui.keys.EndInternalUI(closeCtx, sessionID) + cancel() + } + if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok { + speech.InterruptInternalUI() + } else { + ui.speech.Interrupt() + } + if ui.braille != nil { + ui.braille.SetInternalUIFrame(nil) + } + if result != nil { + if reply.Err == nil { + reply.Err = closeErr + } + result <- reply + close(result) + } + session, sessionID, result, requestDone = nil, 0, nil, nil + target = atspi.ActionTarget{} + return closeErr + } + defer func() { finish(internalUIResult{Cancelled: true}) }() + // Native capture can be revoked by lock, focus change or handoff without a + // command being delivered (secure input deliberately drains command queues). + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-requestDone: + finish(internalUIResult{Cancelled: true}) + case <-ticker.C: + if sessionID != 0 && ui.keys.InternalUISession() != sessionID { + finish(internalUIResult{Cancelled: true}) + } + case notice := <-ui.notices: + if sessionID != 0 && requestDone == notice.ctx.Done() && ui.keys.InternalUISession() == sessionID { + ui.present(notice.value) + } + case request := <-ui.requests: + if sessionID != 0 { + request.ready <- errors.New("internal UI busy") + close(request.result) + continue + } + var err error + sessionID, err = ui.keys.BeginInternalUI(request.ctx) + if err != nil { + request.ready <- err + close(request.result) + continue + } + session, result, requestDone = request.session, request.result, request.ctx.Done() + ui.present(session.Opening()) + request.ready <- nil + case event := <-ui.events: + if event.Command == input.ShowActions { + if sessionID != 0 { + finish(internalUIResult{Cancelled: true}) + } + sessionID = event.UISession + if sessionID == 0 || ui.actions == nil || ui.interest == nil { + finish(internalUIResult{Err: errors.New("actions unavailable")}) + ui.speech.Enqueue(speechRequest{Text: "Actions unavailable", Role: "system", Cause: "internal-ui"}) + if ui.braille != nil { + ui.braille.EnqueueMessage("Actions unavailable") + } + continue + } + lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + interest, err := ui.interest.ObjectOfInterest(lookupCtx) + var actions atspi.ActionSet + if err == nil { + actions, err = ui.actions.AccessibleActions(lookupCtx, interest.Object) + } + cancel() + if ui.keys.InternalUISession() != sessionID { + finish(internalUIResult{Cancelled: true}) + continue + } + if err != nil || len(actions.Actions) == 0 { + finish(internalUIResult{Err: err}) + message := "No actions found" + if err != nil { + message = "Actions unavailable" + } + ui.speech.Interrupt() + ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "internal-ui"}) + if ui.braille != nil { + ui.braille.EnqueueMessage(message) + } + continue + } + items := make([]interaction.Item, 0, len(actions.Actions)) + for _, action := range actions.Actions { + items = append(items, interaction.Item{ID: action.Name, Label: action.Label}) + } + session, err = interaction.NewSession(interaction.Form{Title: "Actions", Elements: []interaction.Element{{ID: "actions", Label: "Actions", Kind: interaction.List, Items: items}}}) + if err != nil { + finish(internalUIResult{Err: err}) + continue + } + target = actions.Target + ui.present(session.Opening()) + continue + } + if session == nil || event.UISession != sessionID { + continue + } + if event.Command == input.InternalUIDismiss { + finish(internalUIResult{Cancelled: true}) + continue + } + key := internal_ui_event(event) + effect := session.Handle(key) + if effect.Cancelled { + finish(internalUIResult{Cancelled: true}) + continue + } + if ui.keys.InternalUISession() != sessionID { + finish(internalUIResult{Cancelled: true}) + continue + } + if effect.Paste { + clipboardCtx, cancel := context.WithTimeout(ctx, time.Second) + text, err := read_internal_clipboard(clipboardCtx) + cancel() + if ui.keys.InternalUISession() != sessionID { + finish(internalUIResult{Cancelled: true}) + continue + } + if err == nil { + effect = session.Insert(text) + } else { + effect.Presentation = interaction.Presentation{Speech: "Clipboard text unavailable", Braille: "Clipboard text unavailable", Cursor: -1} + } + } + if effect.Copy != nil { + copyCtx, cancel := context.WithTimeout(ctx, time.Second) + err := (xclipOCRCopier{}).Copy(copyCtx, *effect.Copy) + cancel() + if ui.keys.InternalUISession() != sessionID { + finish(internalUIResult{Cancelled: true}) + continue + } + message := "Copied" + if err != nil { + message = "Copy failed" + } else if strings.EqualFold(key.Text, "x") { + effect = session.Handle(interaction.Event{Key: interaction.Delete}) + message = "Cut" + } + effect.Presentation = session.Presentation() + effect.Speech = message + } + if effect.Activated != "" { + values := session.Values() + actionTarget := target + closeErr := finish(internalUIResult{Activated: effect.Activated, Values: values}) + if actionTarget.Object.Valid() && closeErr == nil { + actionCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + err := ui.actions.PerformAccessibleAction(actionCtx, actionTarget, effect.Activated) + cancel() + if err != nil && !ui.gate.Locked() && !ui.gate.Suspended() { + ui.speech.Enqueue(speechRequest{Text: "Action unavailable", Role: "system", Cause: "internal-ui"}) + if ui.braille != nil { + ui.braille.EnqueueMessage("Action unavailable") + } + } + } else if actionTarget.Object.Valid() && !ui.gate.Locked() && !ui.gate.Suspended() { + ui.speech.Enqueue(speechRequest{Text: "Action unavailable", Role: "system", Cause: "internal-ui"}) + if ui.braille != nil { + ui.braille.EnqueueMessage("Action unavailable") + } + } + continue + } + if effect.Speech != "" || effect.Braille != "" { + ui.present(effect.Presentation) + } + } + } +} + +func internal_ui_event(event input.CommandEvent) interaction.Event { + key := interaction.Text + switch event.Chord.Symbol { + case 0xff52, 0xff97: + key = interaction.Up + case 0xff54, 0xff99: + key = interaction.Down + case 0xff51, 0xff96: + key = interaction.Left + case 0xff53, 0xff98: + key = interaction.Right + case 0xff50, 0xff95: + key = interaction.Home + case 0xff57, 0xff9c: + key = interaction.End + case 0xff55, 0xff9a: + key = interaction.PageUp + case 0xff56, 0xff9b: + key = interaction.PageDown + case 0xff09, 0xfe20: + key = interaction.Tab + case 0xff0d, 0xff8d: + key = interaction.Enter + case 0xff1b: + key = interaction.Escape + case 0xff08: + key = interaction.Backspace + case 0xffff, 0xff9f: + key = interaction.Delete + } + text := event.Text + if event.Chord.CthulhuModifier { + text = "" + } + return interaction.Event{Key: key, Text: text, Shift: event.Chord.Modifiers&1 != 0, Control: event.Chord.Modifiers&4 != 0, Alt: event.Chord.Modifiers&(8|64) != 0} +} + +func read_internal_clipboard(ctx context.Context) (string, error) { + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out") + output, err := command.StdoutPipe() + if err != nil { + return "", err + } + if err = command.Start(); err != nil { + return "", err + } + data, readErr := io.ReadAll(io.LimitReader(output, 32769)) + if len(data) > 32768 || readErr != nil { + _ = command.Process.Kill() + _ = command.Wait() + return "", errors.New("clipboard text too large") + } + if err = command.Wait(); err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/app/internal_ui_native_test.go b/internal/app/internal_ui_native_test.go new file mode 100644 index 0000000..90883da --- /dev/null +++ b/internal/app/internal_ui_native_test.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeInternalUIActionsAndEntry(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_INTERNAL_UI") != "1" { + t.Skip("requires private GTK/AT-SPI fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := atspi.New() + keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + worker, err := review.NewWorker(adapter, time.Second) + if err != nil { + t.Fatal(err) + } + for _, service := range []interface { + Start(context.Context) error + Stop(context.Context) error + }{adapter, worker, keys} { + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + defer service.Stop(context.Background()) + } + output := &ocrTestOutput{speech: make(chan speechRequest, 256)} + ui := &internalUI{keys: keys, actions: adapter, interest: worker, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})} + go ui.run(ctx) + focused := make(chan struct{}, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-ctx.Done(): + return + case event := <-keys.Commands(): + ui.Handle(event) + case event := <-adapter.Events(): + if event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0 { + worker.SyncFocus(event.Source.ID()) + if event.Source.Name == "Run action" { + select { + case focused <- struct{}{}: + default: + } + } + } + } + } + }() + defer func() { cancel(); <-ui.done; <-done }() + key := func(names ...string) { + t.Helper() + if data, err := exec.CommandContext(ctx, "xdotool", append([]string{"key"}, names...)...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, data) + } + } + key("Tab", "shift+Tab") + select { + case <-focused: + case <-ctx.Done(): + t.Fatal("button did not gain accessible focus") + } + original, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output() + if err != nil { + t.Fatal(err) + } + key("Insert+shift+a") + select { + case message := <-output.speech: + if !strings.Contains(strings.ToLower(message.Text), "click") { + t.Fatalf("accessible actions=%q", message.Text) + } + case <-ctx.Done(): + t.Fatal("actions menu did not open") + } + key("Return") + read := func() (int, string) { + data, err := os.ReadFile(os.Getenv("CTHULHU_UI_MARKER")) + if err != nil { + return 0, "" + } + var value struct { + Clicks int `json:"clicks"` + Text string `json:"text"` + } + if json.Unmarshal(data, &value) != nil { + return 0, "" + } + return value.Clicks, value.Text + } + for { + clicks, _ := read() + if clicks == 1 { + break + } + if ctx.Err() != nil { + t.Fatal("accessible click not performed") + } + time.Sleep(10 * time.Millisecond) + } + originalActions, err := adapter.AccessibleActions(ctx, worker.Focus()) + if err != nil { + t.Fatal(err) + } + result, err := ui.Open(ctx, interaction.Form{Title: "Example", Elements: []interaction.Element{{ID: "code", Label: "Code", Kind: interaction.Entry}}}) + if err != nil { + t.Fatal(err) + } + for keys.InternalUISession() == 0 { + if ctx.Err() != nil { + t.Fatal("entry did not open") + } + time.Sleep(time.Millisecond) + } + key("a", "shift+b", "c", "BackSpace", "exclam", "shift+Home", "ctrl+c") +copied: + for { + select { + case message := <-output.speech: + if message.Text == "Copied" { + break copied + } + case <-ctx.Done(): + t.Fatal("entry copy did not complete") + } + } + clipboardText, err := read_internal_clipboard(ctx) + if err != nil || clipboardText != "aB!" { + t.Fatalf("copied text = %q, %v", clipboardText, err) + } + key("BackSpace", "ctrl+v", "d", "Return") + select { + case submitted := <-result: + if submitted.Err != nil || submitted.Values["code"].Text != "aB!d" { + t.Fatalf("entry result=%+v", submitted) + } + case <-ctx.Done(): + t.Fatal("entry did not submit") + } + current, _ := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output() + if string(current) != string(original) { + t.Fatal("internal UI moved application focus") + } + clicks, text := read() + if clicks != 1 || text != "" { + t.Fatalf("UI keys reached GTK: clicks=%d text=%q", clicks, text) + } + key("Tab", "q") + for { + _, text = read() + if text == "q" { + break + } + if ctx.Err() != nil { + t.Fatalf("ordinary typing did not resume: %q", text) + } + time.Sleep(10 * time.Millisecond) + } + if err := adapter.PerformAccessibleAction(ctx, originalActions.Target, "click"); err == nil { + t.Fatal("stale focus action was performed") + } + clicks, _ = read() + if clicks != 1 { + t.Fatal("stale action clicked old target") + } + // Fresh one-line transfer reads share native foreground/password safeguards. + item, err := adapter.ReadTransferItem(ctx, worker.Focus(), -1) + if err != nil || item.Text != "q" { + t.Fatalf("focused entry transfer=%+v %v", item, err) + } + key("Tab") + for { + item, err = adapter.ReadTransferItem(ctx, worker.Focus(), -1) + if err == nil && item.Text == "First line" { + break + } + if ctx.Err() != nil { + t.Fatalf("multiline transfer=%+v %v", item, err) + } + time.Sleep(10 * time.Millisecond) + } + // A browser's virtual caret can request another line without moving native focus. + item, err = adapter.ReadTransferItem(ctx, worker.Focus(), 12) + if err != nil || item.Text != "Second line" { + t.Fatalf("virtual transfer=%+v %v", item, err) + } + key("ctrl+Tab") + for { + if _, err = adapter.ReadTransferItem(ctx, worker.Focus(), -1); err != nil { + break + } + if ctx.Err() != nil { + t.Fatal("protected item was exported") + } + time.Sleep(10 * time.Millisecond) + } + +} diff --git a/internal/app/internal_ui_output_test.go b/internal/app/internal_ui_output_test.go new file mode 100644 index 0000000..fdf83dd --- /dev/null +++ b/internal/app/internal_ui_output_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "sync/atomic" + "testing" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type uiOutputRecorder struct { + messages []speechRequest + interrupts atomic.Int32 +} + +func (output *uiOutputRecorder) Enqueue(request speechRequest) { + output.messages = append(output.messages, request) +} +func (output *uiOutputRecorder) Interrupt() { output.interrupts.Add(1) } + +func TestInternalUISuppressesApplicationOutputAndInputInterruption(t *testing.T) { + keys := &uiTestKeys{} + keys.session.Store(1) + output := &uiOutputRecorder{} + speaker := gatedSpeech{speech: output, gate: &focusGate{internalUI: keys}} + speaker.Enqueue(speechRequest{Text: "Application event", Cause: "focus"}) + speaker.Interrupt() + if speaker.InterruptForInput(input.Event{}) || len(output.messages) != 0 || output.interrupts.Load() != 0 { + t.Fatal("application output interrupted internal UI") + } + speaker.InterruptInternalUI() + speaker.Enqueue(speechRequest{Text: "Choice", Cause: "internal-ui"}) + if len(output.messages) != 1 || output.interrupts.Load() != 1 { + t.Fatal("internal UI output was suppressed") + } + keys.session.Store(0) + speaker.Enqueue(speechRequest{Text: "Application event", Cause: "focus"}) + if len(output.messages) != 2 { + t.Fatal("normal presentation did not resume") + } +} diff --git a/internal/app/internal_ui_test.go b/internal/app/internal_ui_test.go new file mode 100644 index 0000000..f85d81f --- /dev/null +++ b/internal/app/internal_ui_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/review" +) + +type uiTestKeys struct{ session atomic.Uint64 } + +func (keys *uiTestKeys) InternalUISession() uint64 { return keys.session.Load() } +func (keys *uiTestKeys) InternalUIActive() bool { return keys.session.Load() != 0 } +func (keys *uiTestKeys) BeginInternalUI(context.Context) (uint64, error) { + keys.session.Store(1) + return 1, nil +} +func (keys *uiTestKeys) EndInternalUI(_ context.Context, id uint64) error { + keys.session.CompareAndSwap(id, 0) + return nil +} + +type uiTestActions struct { + keys *uiTestKeys + invoked chan string + object atspi.ObjectID +} + +func (source *uiTestActions) ObjectOfInterest(context.Context) (review.Interest, error) { + return review.Interest{Object: source.object}, nil +} +func (source *uiTestActions) AccessibleActions(_ context.Context, object atspi.ObjectID) (atspi.ActionSet, error) { + return atspi.ActionSet{Target: atspi.ActionTarget{Object: object}, Actions: []atspi.AccessibleAction{{Name: "click", Label: "Click"}, {Name: "press", Label: "Press"}}}, nil +} +func (source *uiTestActions) PerformAccessibleAction(_ context.Context, target atspi.ActionTarget, name string) error { + if source.keys.InternalUISession() != 0 || target.Object != source.object { + source.invoked <- "wrong target or capture retained" + } else { + source.invoked <- name + } + return nil +} + +func TestInternalUIActionsAndFormUseSameHost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + keys := &uiTestKeys{} + keys.session.Store(1) + source := &uiTestActions{keys: keys, object: atspi.NewObjectID(":1.42", "/button"), invoked: make(chan string, 1)} + output := &ocrTestOutput{speech: make(chan speechRequest, 16)} + ui := &internalUI{keys: keys, actions: source, interest: source, speech: output, gate: &focusGate{}, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), done: make(chan struct{})} + go ui.run(ctx) + defer func() { cancel(); <-ui.done }() + ui.Handle(input.CommandEvent{Command: input.ShowActions, UISession: 1}) + select { + case message := <-output.speech: + if message.Cause != "internal-ui" || message.Text != "Actions. Click" { + t.Fatal(message) + } + case <-ctx.Done(): + t.Fatal("menu did not open") + } + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff54}}) + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff0d}}) + select { + case action := <-source.invoked: + if action != "press" { + t.Fatal(action) + } + case <-ctx.Done(): + t.Fatal("action did not run") + } + select { + case message := <-output.speech: + if message.Text != "Press" { + t.Fatalf("navigation repeated title: %q", message.Text) + } + case <-ctx.Done(): + t.Fatal("selection was not announced") + } + result, err := ui.Open(ctx, interaction.Form{Title: "Example", Elements: []interaction.Element{{ID: "entry", Label: "Code", Kind: interaction.Entry}}}) + if err != nil { + t.Fatal(err) + } + for keys.InternalUISession() == 0 { + if ctx.Err() != nil { + t.Fatal("form did not open") + } + time.Sleep(time.Millisecond) + } + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Text: "é", Chord: input.CommandChord{Symbol: 0xe9}}) + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff0d}}) + select { + case submitted := <-result: + if submitted.Activated != "entry" || submitted.Values["entry"].Text != "é" { + t.Fatalf("form=%+v", submitted) + } + case <-ctx.Done(): + t.Fatal("form did not submit") + } +} + +func TestInternalUIBrailleRestoresNewestApplicationFrame(t *testing.T) { + worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} + worker.EnqueueFrame(braille.Frame{Text: "Application", Cursor: 2}) + worker.SetInternalUIFrame(&braille.Frame{Text: "Code, edit: abc", Cursor: 14}) + worker.EnqueueFrame(braille.Frame{Text: "Newest application", Cursor: 4}) + if worker.current().Text != "Code, edit: abc" { + t.Fatal("application overwrote UI") + } + options := config.DefaultSettings().Braille + options.FlashMessages = true + if worker.Flash("Background", options) { + t.Fatal("background flash overwrote UI") + } + worker.SetInternalUIFrame(nil) + if worker.current().Text != "Newest application" || worker.current().Cursor != 4 { + t.Fatal("latest application frame not restored") + } +} diff --git a/internal/app/key_bindings_test.go b/internal/app/key_bindings_test.go new file mode 100644 index 0000000..94f0ccd --- /dev/null +++ b/internal/app/key_bindings_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" + "log/slog" + "path/filepath" + "reflect" + "testing" +) + +type bindingRecorder struct { + values []input.BindingOverride + calls int + err error +} + +func (recorder *bindingRecorder) SetKeyBindings(_ context.Context, values []input.BindingOverride) error { + recorder.calls++ + if recorder.err != nil { + return recorder.err + } + recorder.values = input.CloneBindingOverrides(values) + return nil +} + +func TestPreferencesKeyBindingsApplySaveResetAndFailure(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + recorder := &bindingRecorder{} + worker.bindings = recorder + values := preferences.FromSettings(settings) + values.KeyBindings = []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"Cthulhu+F8"}}} + recorder.err = errors.New("grab conflict") + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if response.Accepted || len(worker.settings.KeyBindings) != 0 { + t.Fatal("failed update committed") + } + recorder.err = nil + response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || !reflect.DeepEqual(values.KeyBindings, recorder.values) { + t.Fatalf("apply: %+v", response) + } + loaded, err := config.Load(settings.Path) + if err != nil || !reflect.DeepEqual(values.KeyBindings, loaded.KeyBindings) { + t.Fatalf("save: %v", err) + } + calls := recorder.calls + values.KeyBindings[0].Keys = []string{"Cthulhu+q"} + response = worker.apply(context.Background(), preferences.Request{Values: values}) + if response.Accepted || recorder.calls != calls { + t.Fatal("invalid bindings reached backend") + } + values.KeyBindings[0].Keys = []string{"Cthulhu+F9"} + rate := 70.0 + values.Rate = &rate + response = worker.apply(context.Background(), preferences.Request{Values: values}) + if response.Accepted || recorder.values[0].Keys[0] != "Cthulhu+F8" { + t.Fatal("later backend failure did not restore keys") + } + values.Rate = nil + values.KeyBindings = nil + response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || len(recorder.values) != 0 { + t.Fatal("reset was not applied") + } +} + +type keyboardRecorder struct { + bindingRecorder + layout input.KeyboardLayout + modifiers []string +} + +func (recorder *keyboardRecorder) SetKeyboardConfiguration(ctx context.Context, layout input.KeyboardLayout, modifiers []string, overrides []input.BindingOverride) error { + if err := recorder.SetKeyBindings(ctx, overrides); err != nil { + return err + } + recorder.layout = layout + recorder.modifiers = append([]string(nil), modifiers...) + return nil +} + +func TestPreferencesKeyboardConfigurationApplySaveRollback(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + recorder := &keyboardRecorder{} + worker.bindings = recorder + values := preferences.FromSettings(settings) + values.KeyboardLayout = 2 + values.CthulhuModifierKeys = []string{"Caps_Lock"} + recorder.err = errors.New("modifier grab conflict") + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if response.Accepted || worker.settings.KeyboardLayout != 1 { + t.Fatal("failed modifier update committed") + } + recorder.err = nil + response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || recorder.layout != input.LaptopKeyboardLayout || !reflect.DeepEqual(recorder.modifiers, values.CthulhuModifierKeys) { + t.Fatalf("apply: %+v", response) + } + loaded, err := config.Load(settings.Path) + if err != nil || loaded.KeyboardLayout != 2 || !reflect.DeepEqual(loaded.CthulhuModifierKeys, values.CthulhuModifierKeys) { + t.Fatalf("save keyboard: %v", err) + } + values.KeyboardLayout = 1 + values.CthulhuModifierKeys = []string{"KP_Insert"} + rate := 70.0 + values.Rate = &rate + response = worker.apply(context.Background(), preferences.Request{Values: values}) + if response.Accepted || recorder.layout != input.LaptopKeyboardLayout || !reflect.DeepEqual(recorder.modifiers, []string{"Caps_Lock"}) { + t.Fatal("later backend failure did not restore keyboard configuration") + } + values.Rate = nil + values.CthulhuModifierKeys = []string{} + response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || len(recorder.modifiers) != 0 { + t.Fatalf("all unchecked: %+v", response) + } + loaded, err = config.Load(settings.Path) + if err != nil || len(loaded.CthulhuModifierKeys) != 0 { + t.Fatalf("reload all unchecked: %v", err) + } +} diff --git a/internal/app/learn_mode.go b/internal/app/learn_mode.go new file mode 100644 index 0000000..21fe856 --- /dev/null +++ b/internal/app/learn_mode.go @@ -0,0 +1,257 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + "unicode" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/shortcutui" + "git.stormux.org/storm/cthulhu/internal/version" +) + +const learnModeInstructions = "Entering learn mode. Press any key to hear its function. Press F1 for Cthulhu documentation, F2 for default shortcuts, or F3 for application shortcuts. Press Escape, Cthulhu H, or Cthulhu F1 to exit." + +type learnModePresenter struct { + tentacles *tentacleRuntime + ctx context.Context + logger *slog.Logger + speech speechEnqueuer + braille interface{ EnqueueMessage(string) bool } + live *liveSettings + browse interface{ BrowseNavigationEnabled() bool } + active bool + lastChord input.CommandChord + lastTime uint32 +} + +func (presenter *learnModePresenter) present(text, cause string) { + presenter.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: cause}) + presenter.braille.EnqueueMessage(text) +} + +func (presenter *learnModePresenter) handle(event input.CommandEvent) { + switch event.Command { + case input.ToggleLearnMode: + entering := event.LearnModeStarted + if entering { + presenter.active = true + presenter.lastTime = 0 + presenter.speech.Interrupt() + presenter.speech.Enqueue(speechRequest{Text: version.String(), Role: "system", Cause: "learn-mode"}) + presenter.speech.Enqueue(speechRequest{Text: learnModeInstructions, Role: "system", Cause: "learn-mode"}) + presenter.braille.EnqueueMessage("Learn mode. Press Escape, Cthulhu H, or Cthulhu F1 to exit.") + return + } + presenter.key(event) + presenter.stop() + case input.LearnKey: + presenter.key(event) + case input.LearnDocumentation: + presenter.key(event) + presenter.stop() + go presenter.openDocumentation() + case input.LearnDefaultShortcuts: + presenter.key(event) + presenter.stop() + presenter.openShortcutList() + case input.LearnApplicationShortcuts: + presenter.key(event) + presenter.stop() + presenter.present("No application-specific screen reader shortcuts are available.", "learn-mode") + } +} + +func (presenter *learnModePresenter) stop() { + presenter.active = false + presenter.lastTime = 0 + presenter.present("Exiting learn mode.", "learn-mode") +} + +func (presenter *learnModePresenter) key(event input.CommandEvent) { + if !presenter.active { + return + } + presenter.speech.Interrupt() + name := spokenLearnKey(event) + presenter.present(name, "learn-key") + settings := presenter.live.Load() + browse := presenter.browse != nil && presenter.browse.BrowseNavigationEnabled() + bindings, err := input.ResolveCommandBindings(input.KeyboardLayout(settings.KeyboardLayout), browse, settings.KeyBindings) + if err == nil { + if command := bindings[event.Chord]; command != "" { + presenter.present(learnCommandDescription(command), "learn-command") + presenter.lastTime = 0 + return + } + } + if description := presenter.tentacles.Describe(event.Chord, input.KeyboardLayout(settings.KeyboardLayout), browse); description != "" { + presenter.present(description, "learn-command") + presenter.lastTime = 0 + return + } + if event.Command == input.LearnKey && len(event.KeyName) == 1 && unicode.IsLetter(rune(event.KeyName[0])) && + presenter.lastChord == event.Chord && event.Timestamp >= presenter.lastTime && event.Timestamp-presenter.lastTime <= 500 { + if word := phoneticLetter(rune(event.KeyName[0])); word != "" { + presenter.present(word, "learn-phonetic") + } + presenter.lastTime = 0 + return + } + presenter.lastChord, presenter.lastTime = event.Chord, event.Timestamp +} + +func spokenLearnKey(event input.CommandEvent) string { + name := event.KeyName + if name == "" { + name = input.FormatCommandChord(input.CommandChord{Symbol: event.Chord.Symbol}) + } + switch name { + case "space": + name = "space" + case "Return": + name = "enter" + case "Escape": + name = "escape" + case "BackSpace": + name = "backspace" + case "Prior": + name = "page up" + case "Next": + name = "page down" + case "Caps_Lock": + name = "caps lock" + } + name = strings.ReplaceAll(name, "_", " ") + var parts []string + if event.Chord.CthulhuModifier { + parts = append(parts, "Cthulhu") + } + if event.Chord.Modifiers&4 != 0 { + parts = append(parts, "control") + } + if event.Chord.Modifiers&8 != 0 { + parts = append(parts, "alt") + } + if event.Chord.Modifiers&64 != 0 { + parts = append(parts, "super") + } + if event.Chord.Modifiers&1 != 0 { + parts = append(parts, "shift") + } + return strings.Join(append(parts, name), " ") +} + +func learnCommandDescription(command input.Command) string { + switch command { + case input.ShowActions: + return "Show the actions available for the current object" + case input.ToggleLearnMode: + return "Enter or exit learn mode." + case input.DisableTentacles: + return "Disable all tentacles until Cthulhu restarts." + case input.Quit: + return "Quit Cthulhu." + case input.CycleProgressBarOutput: + return "Cycle progress bar speech, beeps, speech and beeps, and off." + case input.CycleSpeechMode: + return "Cycle speech, beeps, and off." + case input.Preferences: + return "Open Cthulhu preferences." + case input.ApplicationPreferences: + return "Open preferences for the current application." + case input.ChooseApplicationPreferences: + return "Choose an application and open its preferences." + case input.WhereAmI: + return "Speak details of the current item." + case input.PresentCharacterAttributes: + return "Read the current character's font and formatting attributes." + case input.ReadAll: + return "Read all from the current position." + } + return input.CommandLabel(command) + "." +} + +func (presenter *learnModePresenter) openDocumentation() { + ctx, cancel := context.WithTimeout(presenter.ctx, 8*time.Second) + defer cancel() + for _, tool := range []string{"gio", "xdg-open"} { + arguments := []string{"help:cthulhu"} + if tool == "gio" { + arguments = append([]string{"open"}, arguments...) + } + if err := exec.CommandContext(ctx, tool, arguments...).Run(); err == nil { + return + } + } + presenter.logger.Warn("could not open Cthulhu documentation") + presenter.present("Help is not available.", "learn-mode") +} + +func (presenter *learnModePresenter) openShortcutList() { + settings := presenter.live.Load() + list := makeShortcutList(settings) + if rows := presenter.tentacles.ShortcutRows(input.KeyboardLayout(settings.KeyboardLayout)); len(rows) > 0 { + list.Rows = append(list.Rows, rows...) + list.Title = "Screen reader shortcuts, including tentacles" + } + if len(list.Rows) == 0 { + presenter.present(list.Title, "learn-mode") + return + } + executable, err := os.Executable() + if err != nil { + presenter.present("Shortcut list is not available.", "learn-mode") + return + } + cmd := exec.CommandContext(presenter.ctx, executable, "--shortcut-list-ui") + stdin, err := cmd.StdinPipe() + if err != nil { + presenter.present("Shortcut list is not available.", "learn-mode") + return + } + if err := cmd.Start(); err != nil { + presenter.logger.Warn("start shortcut list", "error", err) + presenter.present("Shortcut list is not available.", "learn-mode") + return + } + go func() { + encoder := json.NewEncoder(stdin) + encodeError := encoder.Encode(list) + stdin.Close() + waitError := cmd.Wait() + if encodeError != nil { + presenter.logger.Warn("send shortcut list", "error", encodeError) + } + if waitError != nil && presenter.ctx.Err() == nil { + presenter.logger.Warn("shortcut list window", "error", waitError) + presenter.present("Shortcut list is not available.", "learn-mode") + } + }() +} + +func makeShortcutList(settings config.Settings) shortcutui.List { + rows := input.BindingRows(input.KeyboardLayout(settings.KeyboardLayout), settings.KeyBindings) + list := shortcutui.List{} + shortcuts := 0 + for _, row := range rows { + if row.Keys == "" { + continue + } + group := "Default" + if row.Scope == "browse" { + group = "Browse mode" + } + list.Rows = append(list.Rows, shortcutui.Row{Group: group, Label: learnCommandDescription(row.Command), Keys: row.Keys}) + shortcuts += len(strings.Split(row.Keys, ";")) + } + list.Title = fmt.Sprintf("%d screen reader default shortcuts found", shortcuts) + return list +} diff --git a/internal/app/learn_mode_test.go b/internal/app/learn_mode_test.go new file mode 100644 index 0000000..7c9b23d --- /dev/null +++ b/internal/app/learn_mode_test.go @@ -0,0 +1,81 @@ +package app + +import ( + "context" + "log/slog" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +type learnSpeechRecorder struct { + texts []string + interrupts int +} + +func (recorder *learnSpeechRecorder) Enqueue(request speechRequest) { + recorder.texts = append(recorder.texts, request.Text) +} +func (recorder *learnSpeechRecorder) Interrupt() { recorder.interrupts++ } + +type learnBrailleRecorder struct{ texts []string } + +func (recorder *learnBrailleRecorder) EnqueueMessage(text string) bool { + recorder.texts = append(recorder.texts, text) + return true +} + +func TestLearnModePresentsKeyCommandsWithoutExecutingThem(t *testing.T) { + live := &liveSettings{} + live.Store(config.DefaultSettings()) + speech := &learnSpeechRecorder{} + braille := &learnBrailleRecorder{} + presenter := &learnModePresenter{ctx: context.Background(), logger: slog.New(slog.DiscardHandler), speech: speech, braille: braille, live: live} + presenter.handle(input.CommandEvent{Command: input.ToggleLearnMode, LearnModeStarted: true}) + if !presenter.active || len(speech.texts) != 2 || !strings.Contains(speech.texts[1], "Cthulhu F1") || len(braille.texts) != 1 { + t.Fatalf("start speech=%q braille=%q", speech.texts, braille.texts) + } + presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: input.CommandChord{Symbol: 'q', CthulhuModifier: true}, KeyName: "q", Timestamp: 100}) + if got := speech.texts[len(speech.texts)-1]; got != "Quit Cthulhu." { + t.Fatalf("command description = %q", got) + } + if got := braille.texts[len(braille.texts)-1]; got != "Quit Cthulhu." { + t.Fatalf("braille description = %q", got) + } + presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: input.CommandChord{Symbol: 'z'}, KeyName: "z", Timestamp: 1000}) + presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: input.CommandChord{Symbol: 'z'}, KeyName: "z", Timestamp: 1200}) + if got := speech.texts[len(speech.texts)-1]; got != "zulu" { + t.Fatalf("phonetic double press = %q", got) + } + presenter.handle(input.CommandEvent{Command: input.ToggleLearnMode, Chord: input.CommandChord{Symbol: 'h', CthulhuModifier: true}, KeyName: "h"}) + if presenter.active || speech.texts[len(speech.texts)-1] != "Exiting learn mode." || speech.interrupts < 4 { + t.Fatalf("exit speech=%q interrupts=%d", speech.texts, speech.interrupts) + } +} + +func TestLearnShortcutListUsesActiveBindings(t *testing.T) { + settings := config.DefaultSettings() + settings.KeyBindings = []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"Cthulhu+Ctrl+F8"}}} + list := makeShortcutList(settings) + if len(list.Rows) == 0 || !strings.Contains(list.Title, "shortcuts found") { + t.Fatalf("empty shortcut list: %+v", list) + } + foundLearn := false + foundPreferences := false + for _, row := range list.Rows { + if row.Label == "Enter or exit learn mode." { + foundLearn = strings.Contains(row.Keys, "Cthulhu+h") && strings.Contains(row.Keys, "Cthulhu+F1") + } + if row.Label == "Open Cthulhu preferences." { + foundPreferences = true + if row.Keys != "Cthulhu+Ctrl+F8" { + t.Fatalf("preferences keys = %q", row.Keys) + } + } + } + if !foundLearn || !foundPreferences { + t.Fatalf("learn and preferences shortcuts missing from list: learn=%t preferences=%t", foundLearn, foundPreferences) + } +} diff --git a/internal/app/linked_headings_native_test.go b/internal/app/linked_headings_native_test.go new file mode 100644 index 0000000..1cc9fbc --- /dev/null +++ b/internal/app/linked_headings_native_test.go @@ -0,0 +1,166 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeLinkedHeadings(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_LINKED_HEADINGS") != "1" { + t.Skip("requires private linked heading browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil { + t.Fatalf("fixture focus: %s, %v", output, err) + } + var focus atspi.ObjectID + var bounds atspi.Rectangle + for !focus.Valid() && ctx.Err() == nil { + tree, err := adapter.CaptureDesktop(ctx) + if err == nil { + focus = linkedHeadingFixtureFocus(tree) + if focus.Valid() { + ref, _ := tree.Reference(focus) + geometry, _ := tree.Geometry(ref) + if geometry.BoundsKnown { + bounds = geometry.Bounds + } + } + } + if !focus.Valid() { + time.Sleep(100 * time.Millisecond) + } + } + if !focus.Valid() { + t.Fatal("linked heading fixture input absent from accessible desktop") + } + if !bounds.Valid() { + t.Fatal("linked heading fixture input has no screen bounds") + } + keys := []string{"key", "--clearmodifiers", "ctrl+l"} + if output, err := exec.CommandContext(ctx, "xdotool", keys...).CombinedOutput(); err != nil { + t.Fatalf("focus browser chrome: %s, %v", output, err) + } + pointX, pointY := bounds.X+bounds.Width/2, bounds.Y+bounds.Height/2 + if output, err := exec.CommandContext(ctx, "xdotool", "mousemove", "--sync", fmt.Sprint(pointX), fmt.Sprint(pointY), "click", "1").CombinedOutput(); err != nil { + t.Fatalf("focus fixture input: %s, %v", output, err) + } + focused := false + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for !focused { + select { + case event := <-adapter.Events(): + focused = event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == focus + case <-deadline.C: + t.Fatal("fixture input did not emit native focus") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + for _, tc := range []struct { + level int + name, title string + moves int + }{ + {2, "Inside heading link", "Cthulhu Linked Heading Inside", 1}, + {3, "Outside heading link", "Cthulhu Linked Heading Outside", 1}, + {3, "#1 Username Example date", "Cthulhu Linked Heading Post", 2}, + } { + tree, err := adapter.CaptureDocument(ctx, focus) + if err != nil { + t.Fatal(err) + } + cursor, err := review.NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, focus); err != nil { + t.Fatal(err) + } + move, err := cursor.Move(ctx, review.DocumentHeading, tc.level, false) + for step := 1; step < tc.moves && err == nil; step++ { + move, err = cursor.Move(ctx, review.DocumentHeading, tc.level, false) + } + if err != nil || !move.Moved || move.Position.Snapshot.Name != tc.name || move.Position.LinkedHeading == nil { + t.Fatalf("heading level %d: %+v, %v", tc.level, move, err) + } + target, err := cursor.ActivationTarget() + if err != nil { + t.Fatal(err) + } + link, err := tree.Resolve(target) + if err != nil || link.Role != "link" { + t.Fatalf("activation target = %+v, %v", link, err) + } + if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil { + t.Fatalf("activate %s: %v", tc.name, err) + } + for { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", tc.title).Output() + if err == nil && strings.TrimSpace(string(output)) != "" { + break + } + if ctx.Err() != nil { + t.Fatalf("activation did not open %s: %s", tc.title, output) + } + time.Sleep(50 * time.Millisecond) + } + } + tree, err := adapter.CaptureDocument(ctx, focus) + if err != nil { + t.Fatal(err) + } + cursor, err := review.NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, focus); err != nil { + t.Fatal(err) + } + move, err := cursor.Move(ctx, review.DocumentHeading, 5, false) + if err != nil || !move.Moved || move.Position.LinkedHeading != nil { + t.Fatalf("plain-text heading with multiple links: %+v, %v", move, err) + } + if target, err := cursor.ActivationTarget(); !errors.Is(err, review.ErrNoCurrent) { + t.Fatalf("ambiguous activation target = %v, %v", target, err) + } +} + +func linkedHeadingFixtureFocus(tree *atspi.AccessibleTree) atspi.ObjectID { + root, err := tree.Root() + if err != nil { + return atspi.ObjectID{} + } + pending := []atspi.ObjectReference{root} + for len(pending) != 0 { + current := pending[len(pending)-1] + pending = pending[:len(pending)-1] + snapshot, err := tree.Resolve(current) + if err != nil { + continue + } + if snapshot.Name == "Probe start" && snapshot.Role == "entry" { + return current.ID() + } + for child, ok, err := tree.FirstChild(current); err == nil && ok; child, ok, err = tree.NextSibling(child) { + pending = append(pending, child) + } + } + return atspi.ObjectID{} +} diff --git a/internal/app/live_region_test.go b/internal/app/live_region_test.go new file mode 100644 index 0000000..3ed087b --- /dev/null +++ b/internal/app/live_region_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestLiveRegionSpeaksInsertedChatWithoutEditableFocus(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + }() + defer func() { cancel(); <-done }() + + region := atspi.Snapshot{ + BusName: ":1.7", Path: "/chat/status", Role: "status", + LiveRegion: true, LivePoliteness: "polite", LiveAtomic: true, + Text: "Username: Test message", + } + placeholder := region + placeholder.Text = "\ufffc" + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, placeholder, "\ufffc") + select { + case got := <-speech.spoken: + t.Fatalf("placeholder spoken as %q", got) + case <-time.After(30 * time.Millisecond): + } + adapter.events <- atspi.NewEvent(atspi.ChildrenAddedEvent, region) + select { + case got := <-speech.spoken: + if got != "Username: Test message" { + t.Fatalf("speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("live region was not spoken") + } + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, region, "Username: Test message") + select { + case got := <-speech.spoken: + t.Fatalf("duplicate live region speech %q", got) + case <-time.After(30 * time.Millisecond): + } + region.Text = "Username: Next message" + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, region, "Next message") + select { + case got := <-speech.spoken: + if got != "Username: Next message" { + // Atomic regions speak their current full content, supplied by the + // adapter, rather than only the insertion payload. + t.Fatalf("atomic speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("atomic live region insertion was not spoken") + } +} + +func TestLiveRegionOffDoesNotSpeak(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + }() + defer func() { cancel(); <-done }() + + region := atspi.Snapshot{BusName: ":1.7", Path: "/chat/status", Role: "status", LiveRegion: true, LivePoliteness: "off", Text: "Silence"} + adapter.events <- atspi.NewEvent(atspi.ChildrenAddedEvent, region) + select { + case got := <-speech.spoken: + t.Fatalf("unexpected speech %q", got) + case <-time.After(30 * time.Millisecond): + } +} diff --git a/internal/app/live_settings.go b/internal/app/live_settings.go new file mode 100644 index 0000000..0fb3975 --- /dev/null +++ b/internal/app/live_settings.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/wine" + "sync/atomic" +) + +// Published settings are immutable snapshots shared by the preferences, input, +// and accessibility workers. Each event uses one complete snapshot. +type liveSettings struct { + current atomic.Pointer[config.Settings] + application atomic.Pointer[string] + requestedApplication atomic.Pointer[string] + clipboardChanges chan struct{} + onApplication func(string) + wineTitles chan wineWindowTitle + wineReview *wine.ReviewRegistry +} + +func (live *liveSettings) Store(settings config.Settings) { + live.current.Store(&settings) + live.notify_clipboard() +} +func (live *liveSettings) notify_clipboard() { + select { + case live.clipboardChanges <- struct{}{}: + default: + } +} +func (live *liveSettings) Load() config.Settings { return *live.current.Load() } diff --git a/internal/app/mouse_review.go b/internal/app/mouse_review.go new file mode 100644 index 0000000..291a1b4 --- /dev/null +++ b/internal/app/mouse_review.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "log/slog" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +type pointReviewAdapter interface { + ReviewAtPoint(context.Context, int, int, int) (atspi.PointReview, error) +} + +type mouseReviewState struct { + ready bool + last atspi.PointReview + spoken string + position xorg.PointerPosition +} + +func (state *mouseReviewState) reset() { *state = mouseReviewState{} } + +func (state *mouseReviewState) clearItem(position xorg.PointerPosition) { + state.ready = true + state.last = atspi.PointReview{} + state.spoken = "" + state.position = position +} + +func (state *mouseReviewState) text(point atspi.PointReview, settings config.Settings) string { + return state.presentation(point, settings).Text +} + +func (state *mouseReviewState) presentation(point atspi.PointReview, settings config.Settings) presentation.Speech { + if !point.Snapshot.ID().Valid() || point.Snapshot.Defunct { + return presentation.Speech{} + } + if !state.ready { + state.ready = true + state.last = point + return presentation.Speech{} + } + changedObject := point.Snapshot.ID() != state.last.Snapshot.ID() || point.Window != state.last.Window + var value string + var spoken presentation.Speech + if changedObject { + snapshot := point.Snapshot + if snapshot.Role != "password text" && point.Line != snapshot.Name { + snapshot.Text = point.Line + } + spoken = presentation.FocusSpeechWithOptions(snapshot, presentationSettingsFromConfig(settings, true)) + value = spoken.Text + } else if point.LineStart != state.last.LineStart && point.Line != "" { + value = point.Line + } else if point.Word != "" && point.Word != state.last.Word { + value = point.Word + } + state.last = point + value = strings.TrimSpace(value) + if value == state.spoken && !changedObject { + return presentation.Speech{} + } + if value != "" { + state.spoken = value + } + spoken.Text = value + return spoken +} + +// Mouse review owns its pointer connection and last spoken item for one +// enabled session. Only copied snapshots leave the AT-SPI owner thread. +func mouseReviewService(logger *slog.Logger, adapter pointReviewAdapter, speech speechEnqueuer, + braille interface{ EnqueueMessage(string) bool }, live *liveSettings, gate *focusGate) lifecycle.Service { + var cancel context.CancelFunc + var done chan struct{} + return lifecycle.Service{Name: "mouse review", Start: func(ctx context.Context) error { + ctx, cancel = context.WithCancel(ctx) + done = make(chan struct{}) + go func() { + defer close(done) + pointer := xorg.OpenPointerReader() + if pointer == nil { + logger.Warn("mouse review pointer unavailable") + return + } + defer pointer.Close() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + var state mouseReviewState + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + settings := live.Load() + if !settings.Reading.EnableMouseReview || gate.Locked() { + state.reset() + continue + } + position := pointer.Read() + if !position.Known { + state.reset() + continue + } + if position.PID <= 0 { + state.clearItem(position) + continue + } + if state.ready && position == state.position { + continue + } + queryCtx, stop := context.WithTimeout(ctx, 2*time.Second) + point, err := adapter.ReviewAtPoint(queryCtx, position.X, position.Y, position.PID) + stop() + if err != nil { + if ctx.Err() == nil { + logger.Debug("mouse review hit test", "error", err) + } + continue + } + if pointer.Read() != position { + continue + } + settings = live.Load() + if !settings.Reading.EnableMouseReview || gate.Locked() { + state.reset() + continue + } + if !point.Snapshot.ID().Valid() { + state.clearItem(position) + continue + } + if point.Snapshot.Role == "tool tip" && !settings.Reading.PresentTooltips { + state.position = position + continue + } + objectChanged := point.Snapshot.ID() != state.last.Snapshot.ID() || point.Window != state.last.Window + dx, dy := position.X-state.position.X, position.Y-state.position.Y + interrupt := objectChanged || dx*dx+dy*dy > 25*25 + spoken := state.presentation(point, settings) + message := spoken.Text + state.position = position + if message == "" { + continue + } + if interrupt { + speech.Interrupt() + } + speech.Enqueue(speechRequest{Text: message, Chunks: spoken.Chunks, Role: point.Snapshot.Role, Cause: "mouse-review"}) + braille.EnqueueMessage(message) + logger.Debug("mouse review presentation", "bus", point.Snapshot.BusName, + "path", point.Snapshot.Path, "window", point.Window.Path, "command", "mouse-review", + "x", position.X, "y", position.Y, "native_offset", point.Offset, + "virtual_offset", -1, "selection_start", -1, "selection_end", -1, + "speech", message, "braille", message) + } + }() + return nil + }, Stop: func(ctx context.Context) error { + if cancel != nil { + cancel() + } + if done != nil { + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + }} +} diff --git a/internal/app/mouse_review_test.go b/internal/app/mouse_review_test.go new file mode 100644 index 0000000..0dcd0e2 --- /dev/null +++ b/internal/app/mouse_review_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestMouseReviewChangesObjectLineAndWordWithoutMovingFocus(t *testing.T) { + settings := config.DefaultSettings() + point := atspi.PointReview{Snapshot: atspi.Snapshot{BusName: ":1.9", Path: "/entry", Name: "Search", Role: "entry"}, + Window: atspi.ObjectID{BusName: ":1.9", Path: "/window"}, Word: "first", Line: "first second", LineStart: 0} + var state mouseReviewState + if got := state.text(point, settings); got != "" { + t.Fatalf("initial pointer spoke %q", got) + } + point.Word = "second" + if got := state.text(point, settings); got != "second" { + t.Fatalf("new word = %q", got) + } + if got := state.text(point, settings); got != "" { + t.Fatalf("duplicate word = %q", got) + } + point.Line, point.LineStart, point.Word = "next line", 12, "next" + if got := state.text(point, settings); got != "next line" { + t.Fatalf("new line = %q", got) + } + point.Snapshot.Path, point.Snapshot.Name, point.Snapshot.Role = "/button", "Save", "push button" + point.Word, point.Line = "", "" + if got := state.text(point, settings); !strings.Contains(got, "Save") || !strings.Contains(got, "button") { + t.Fatalf("new object = %q", got) + } + point.Snapshot.Path, point.Snapshot.Name, point.Snapshot.Role = "/password", "Password", "password text" + point.Word, point.Line = "secret", "secret" + if got := state.text(point, settings); strings.Contains(got, "secret") { + t.Fatalf("password leaked: %q", got) + } + state.clearItem(state.position) + if got := state.text(point, settings); !strings.Contains(got, "Password") { + t.Fatalf("object re-entry = %q", got) + } +} diff --git a/internal/app/named_links_native_test.go b/internal/app/named_links_native_test.go new file mode 100644 index 0000000..cf9e7b7 --- /dev/null +++ b/internal/app/named_links_native_test.go @@ -0,0 +1,139 @@ +// 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/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeBrowserNamedLinks(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 := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil { + t.Fatalf("fixture focus: %s, %v", output, err) + } + var focus atspi.ObjectID + for !focus.Valid() { + select { + case event := <-adapter.Events(): + if event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Probe start" { + focus = event.Source.ID() + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + tree, err := adapter.CaptureDocument(ctx, focus) + if err != nil { + t.Fatal(err) + } + cursor, err := review.NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, focus); err != nil { + t.Fatal(err) + } + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + for _, step := range []struct { + command review.DocumentCaretCommand + text string + }{ + {review.CaretNextLine, "Example Game 2 copies"}, + {review.CaretNextLine, "Ordinary multiline link"}, + {review.CaretNextCharacter, "r"}, + {review.CaretNextWord, "Ordinary"}, + {review.CaretNextWord, "multiline"}, + {review.CaretNextLine, "Useful image label"}, + {review.CaretPreviousLine, "Ordinary multiline link"}, + {review.CaretPreviousLine, "Example Game 2 copies"}, + } { + move, err := cursor.MoveCaret(ctx, adapter, step.command) + t.Logf("%v: path=%s role=%s text=%q offset=%d range=%d:%d count=%d", step.command, move.Position.Object.ID().Path, move.Position.Snapshot.Role, move.Text, move.Position.Offset, move.Start, move.End, move.Position.Snapshot.CharacterCount) + if err != nil || !move.Moved { + t.Fatalf("%v: %+v, %v", step.command, move, err) + } + if step.text == "Example Game 2 copies" && !move.Position.Snapshot.ExplicitName { + t.Fatal("native explicit-name attribute was not captured") + } + context, err := tree.DocumentContext(move.Position.Object) + if err != nil { + t.Fatal(err) + } + result := review.Result{DocumentCaretCommand: step.command, DocumentPosition: move.Position, + DocumentCaretText: move.Text, DocumentCaretContext: context.Ancestors, Moved: move.Moved} + presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions()) + select { + case spoken := <-queue.spoken: + if !strings.Contains(spoken, step.text) { + t.Fatalf("%v spoke %q, want %q", step.command, spoken, step.text) + } + default: + t.Fatal("native browse result was silent") + } + } + reference, err := tree.Reference(focus) + if err != nil { + t.Fatal(err) + } + document, err := tree.Document(ctx, reference) + if err != nil { + t.Fatal(err) + } + var username, emoji atspi.ObjectReference + for _, node := range document.Nodes { + if node.Snapshot.Role != "static" { + continue + } + switch strings.TrimSpace(node.Snapshot.Name) { + case "Username": + username = node.Reference + case "🧻": + emoji = node.Reference + } + } + if !username.Valid() || !emoji.Valid() { + t.Fatal("separate display-name and emoji text objects missing") + } + word, err := adapter.ReadDocumentCaret(ctx, emoji, 0, atspi.DocumentCaretWord) + t.Logf("native emoji word: %+v, %v", word, err) + if _, err := cursor.SyncFocus(ctx, username.ID()); err != nil { + t.Fatal(err) + } + for _, step := range []struct { + command review.DocumentCaretCommand + text string + }{ + {review.CaretNextWord, "Username"}, + {review.CaretNextWord, "🧻"}, + {review.CaretNextWord, "After"}, + {review.CaretPreviousWord, "After"}, + {review.CaretPreviousWord, "🧻"}, + {review.CaretPreviousWord, "Username"}, + } { + move, err := cursor.MoveCaret(ctx, adapter, step.command) + if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.text { + t.Fatalf("word navigation across emoji, %v: %+v, %v; want %q", step.command, move, err, step.text) + } + } +} diff --git a/internal/app/notification_list_test.go b/internal/app/notification_list_test.go new file mode 100644 index 0000000..7e97dc6 --- /dev/null +++ b/internal/app/notification_list_test.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/notificationui" +) + +func TestNotificationListSnapshotMatchesReferenceLayout(t *testing.T) { + presenter, controller, speech := notificationListFixture(t) + worker := newNotificationListWorker(nil, presenter, speech) + response := worker.snapshot("") + + if response.Title != "2 notifications" { + t.Fatalf("title = %q, want %q", response.Title, "2 notifications") + } + if response.Selected != "1" { + t.Fatalf("selected = %q, want %q", response.Selected, "1") + } + if len(response.Entries) != 2 { + t.Fatalf("entry count = %d, want 2", len(response.Entries)) + } + latest := response.Entries[0] + if latest.Message != "Live message" || latest.Received != "0 seconds ago" || !latest.CanControl { + t.Fatalf("latest entry = %+v", latest) + } + if len(latest.Actions) != 2 || latest.Actions[0].Key != "default" || latest.Actions[1].Key != "reply" { + t.Fatalf("actions = %+v, want sorted default and reply", latest.Actions) + } + if response.Entries[1].CanControl { + t.Fatalf("older generic entry unexpectedly controllable: %+v", response.Entries[1]) + } + if len(controller.dismissed) != 0 || len(controller.invoked) != 0 { + t.Fatalf("snapshot unexpectedly changed controller state: %+v", controller) + } +} + +func TestNotificationListActionsMatchReferenceBehavior(t *testing.T) { + tests := []struct { + name string + request notificationui.Request + wantClose bool + wantSpeech string + }{ + { + name: "copy", + request: notificationui.Request{Copy: true}, + wantSpeech: messageClipboardCopiedFull, + }, + { + name: "dismiss", + request: notificationui.Request{Dismiss: true, Selection: "1", Row: intPointer(0)}, + wantSpeech: notification.MessageDismissed, + }, + { + name: "invoke action", + request: notificationui.Request{Action: "reply", Selection: "1", Row: intPointer(0)}, + wantSpeech: notification.MessageActionInvoked, + }, + { + name: "clear", + request: notificationui.Request{Clear: true}, + wantClose: true, + wantSpeech: notification.MessageNoMessages, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + presenter, controller, speech := notificationListFixture(t) + worker := newNotificationListWorker(nil, presenter, speech) + response, close := worker.handle(test.request) + if close != test.wantClose { + t.Fatalf("close = %v, want %v", close, test.wantClose) + } + select { + case message := <-speech.spoken: + if message != test.wantSpeech { + t.Fatalf("speech = %q, want %q", message, test.wantSpeech) + } + default: + t.Fatal("no speech was presented") + } + if test.request.Clear && len(presenter.Entries()) != 0 { + t.Fatalf("history length after clear = %d, want 0", len(presenter.Entries())) + } + if test.request.Dismiss && len(presenter.Entries()) != 1 { + t.Fatalf("history length after dismiss = %d, want 1", len(presenter.Entries())) + } + if test.request.Action != "" && controller.invoked[0].key != "reply" { + t.Fatalf("invoked = %+v, want reply", controller.invoked) + } + if response.Selected == "" && !test.wantClose { + t.Fatalf("selected = %q, want a live selection", response.Selected) + } + }) + } +} + +func notificationListFixture(t *testing.T) (*notification.Presenter, *notificationListController, *fakeSpeechQueue) { + t.Helper() + now := time.Unix(1_800_000_000, 0) + presenter := notification.NewPresenterAt(func() time.Time { return now }) + controller := ¬ificationListController{} + presenter.SetMakoController(controller) + presenter.Save("Older message") + identifier := 42 + presenter.Save("Live message", notification.SaveOptions{ + Source: notification.MakoSource, NotificationID: &identifier, Live: true, + Actions: map[string]string{"reply": "Reply", "default": "View"}, + }) + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + return presenter, controller, speech +} + +func intPointer(value int) *int { return &value } + +type notificationListAction struct { + id int + key string +} + +type notificationListController struct { + dismissed []int + invoked []notificationListAction +} + +func (controller *notificationListController) IsCurrent(notification.Entry) bool { return true } +func (controller *notificationListController) Dismiss(id int) bool { + controller.dismissed = append(controller.dismissed, id) + return true +} +func (controller *notificationListController) InvokeAction(id int, key string) bool { + controller.invoked = append(controller.invoked, notificationListAction{id: id, key: key}) + return true +} diff --git a/internal/app/notification_test.go b/internal/app/notification_test.go new file mode 100644 index 0000000..1e37a78 --- /dev/null +++ b/internal/app/notification_test.go @@ -0,0 +1,138 @@ +// 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" + "git.stormux.org/storm/cthulhu/internal/notification" +) + +func TestNotificationProvidersAndHistoryMatchReferenceBehavior(t *testing.T) { + tests := []struct { + name string + event atspi.Event + expectedSpeech []string + expectedHistory string + }{ + { + name: "xfce notification daemon labels", + event: notificationSnapshot("xfce4-notifyd", atspi.WindowCreateEvent, atspi.NewSnapshot( + "/notification", "", "frame", "", "Mail arrived New message", + )), + expectedSpeech: []string{"Notification Mail arrived New message"}, + expectedHistory: "Notification Mail arrived New message", + }, + { + name: "notify-osd name without value announces prefix and message", + event: notificationSnapshot("notify-osd", atspi.AccessibleNameEvent, atspi.NewSnapshot( + "/notification", "Volume", "notification", "at maximum", "", + )), + expectedSpeech: []string{"Notification", "Volume at maximum"}, + expectedHistory: "Volume at maximum", + }, + { + name: "notify-osd gauge value is not saved", + event: gaugeSnapshot(), + expectedSpeech: []string{"55"}, + expectedHistory: "", + }, + { + name: "generic accessible notification is saved", + event: showingNotification("Mail", "You have one unread message", true), + expectedSpeech: []string{"Mail, notification, You have one unread message"}, + expectedHistory: "Mail You have one unread message", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + presenter := notification.NewPresenter() + speech, entries := processNotificationEvent(t, test.event, presenter) + if len(speech) != len(test.expectedSpeech) { + t.Fatalf("speech count = %d, want %d: %q", len(speech), len(test.expectedSpeech), speech) + } + for index, expected := range test.expectedSpeech { + if speech[index] != expected { + t.Fatalf("speech %d = %q, want %q", index, speech[index], expected) + } + } + if test.expectedHistory == "" { + if len(entries) != 0 { + t.Fatalf("history length = %d, want 0", len(entries)) + } + return + } + if len(entries) != 1 { + t.Fatalf("history length = %d, want 1", len(entries)) + } + if entries[0].Message != test.expectedHistory { + t.Fatalf("history = %q, want %q", entries[0].Message, test.expectedHistory) + } + }) + } +} + +func TestHiddenGenericNotificationIsNotSaved(t *testing.T) { + event := showingNotification("Mail", "You have one unread message", false) + presenter := notification.NewPresenter() + _, entries := processNotificationEvent(t, event, presenter) + if len(entries) != 0 { + t.Fatalf("history length = %d, want 0", len(entries)) + } +} + +func showingNotification(name, text string, showing bool) atspi.Event { + event := notificationSnapshot("Application", atspi.ShowingStateEvent, + atspi.NewSnapshot("/notification", name, "notification", "", text)) + if showing { + event.Detail1 = 1 + } + return event +} + +func notificationSnapshot(application, eventType string, snapshot atspi.Snapshot) atspi.Event { + snapshot.ApplicationName = application + return atspi.NewEvent(eventType, snapshot) +} + +func gaugeSnapshot() atspi.Event { + snapshot := atspi.NewSnapshot("/notification", "Volume", "notification", "", "") + snapshot.ValueKnown = true + snapshot.CurrentValue = 55 + return notificationSnapshot("notify-osd", atspi.ValueChangedEvent, snapshot) +} + +func processNotificationEvent(t *testing.T, event atspi.Event, presenter *notification.Presenter) ([]string, []notification.Entry) { + t.Helper() + speech := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + adapter.events <- event + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + go func() { + completed <- processEventsWithInput( + ctx, slog.New(slog.DiscardHandler), speech, adapter, + nil, nil, 0, nil, nil, nil, presenter, + ) + }() + var messages []string + for { + select { + case message := <-speech.spoken: + messages = append(messages, message) + case <-time.After(50 * time.Millisecond): + cancel() + if err := <-completed; err != nil { + t.Fatalf("process events: %v", err) + } + return messages, presenter.Entries() + } + } +} diff --git a/internal/app/notifications.go b/internal/app/notifications.go new file mode 100644 index 0000000..0aed711 --- /dev/null +++ b/internal/app/notifications.go @@ -0,0 +1,276 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "os/exec" + "sort" + "strconv" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/notificationui" + "git.stormux.org/storm/cthulhu/internal/platform/session" +) + +const messageClipboardCopiedFull = "Copied selection to clipboard." + +type notificationListWorker struct { + logger *slog.Logger + presenter *notification.Presenter + speech speechEnqueuer + open chan struct{} + done chan struct{} + cancel context.CancelFunc + once sync.Once +} + +func newNotificationListWorker(logger *slog.Logger, presenter *notification.Presenter, speech speechEnqueuer) *notificationListWorker { + return ¬ificationListWorker{ + logger: logger, presenter: presenter, speech: speech, + open: make(chan struct{}, 1), done: make(chan struct{}), + } +} + +func (worker *notificationListWorker) Start(ctx context.Context) error { + ctx, worker.cancel = context.WithCancel(ctx) + go func() { + defer close(worker.done) + for { + select { + case <-ctx.Done(): + return + case <-worker.open: + if err := worker.window(ctx); err != nil && ctx.Err() == nil { + worker.logger.Error("notification list window", "error", err) + } + } + } + }() + return nil +} + +func (worker *notificationListWorker) Open() { + worker.presenter.RefreshLive() + if len(worker.presenter.Entries()) == 0 { + presentNotifications(worker.speech, []string{notification.MessageNoMessages}) + return + } + select { + case worker.open <- struct{}{}: + default: + } +} + +func (worker *notificationListWorker) Stop(ctx context.Context) error { + worker.once.Do(func() { + if worker.cancel != nil { + worker.cancel() + } + }) + select { + case <-worker.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (worker *notificationListWorker) window(ctx context.Context) error { + executable, err := os.Executable() + if err != nil { + return err + } + childCtx, stop := context.WithCancel(ctx) + defer stop() + command := exec.CommandContext(childCtx, executable, "--notification-list-ui") + command.Env = session.GTKEnvironment() + command.WaitDelay = time.Second + in, err := command.StdinPipe() + if err != nil { + return err + } + out, err := command.StdoutPipe() + if err != nil { + in.Close() + return err + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + in.Close() + out.Close() + return err + } + defer func() { stop(); in.Close(); out.Close(); _ = command.Wait() }() + + encoder := json.NewEncoder(in) + if err := encoder.Encode(worker.snapshot("")); err != nil { + return err + } + requests := make(chan notificationui.Request, 1) + readDone := make(chan error, 1) + readerStopped := make(chan struct{}) + go func() { + defer close(readerStopped) + defer stop() + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 4096), 16*1024) + for scanner.Scan() { + var request notificationui.Request + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { + readDone <- err + return + } + select { + case requests <- request: + case <-childCtx.Done(): + readDone <- childCtx.Err() + return + } + } + readDone <- scanner.Err() + }() + defer func() { + stop() + out.Close() + <-readerStopped + }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-worker.open: + if err := encoder.Encode(notificationui.Response{Present: true}); err != nil { + return err + } + case err := <-readDone: + if errors.Is(err, io.EOF) { + return nil + } + return err + case request := <-requests: + response, close := worker.handle(request) + if close { + response.Close = true + } + if err := encoder.Encode(response); err != nil { + return err + } + if close { + return nil + } + } + } +} + +func (worker *notificationListWorker) handle(request notificationui.Request) (notificationui.Response, bool) { + worker.presenter.RefreshLive() + entries := worker.listEntries() + selected := notification.Entry{} + for _, entry := range entries { + if entry.ID == request.Selection { + selected = entry + break + } + } + switch { + case request.Copy: + presentNotifications(worker.speech, []string{messageClipboardCopiedFull}) + case request.Dismiss: + if selected.ID == "" { + presentNotifications(worker.speech, []string{notification.MessageUnavailable}) + } else { + messages, _ := worker.presenter.DismissEntry(selected) + presentNotifications(worker.speech, messages) + } + case request.Clear: + worker.presenter.Clear() + presentNotifications(worker.speech, []string{notification.MessageNoMessages}) + return notificationui.Response{}, true + case request.Action != "": + if selected.ID == "" { + presentNotifications(worker.speech, []string{notification.MessageUnavailable}) + } else { + messages, _ := worker.presenter.InvokeActionEntry(selected, request.Action) + presentNotifications(worker.speech, messages) + } + } + if request.Dismiss { + return worker.snapshot(worker.selectionAfterRemoval(request)), false + } + return worker.snapshot(request.Selection), false +} + +func (worker *notificationListWorker) snapshot(selected string) notificationui.Response { + entries := worker.listEntries() + response := notificationui.Response{ + Title: worker.presenter.ListTitle(), Entries: make([]notificationui.Entry, len(entries)), + } + for index, entry := range entries { + response.Entries[index] = notificationui.Entry{ + ID: entry.ID, Message: entry.Message, Received: worker.presenter.ReceivedTime(entry.Timestamp), + CanControl: worker.presenter.CanControlEntry(entry), Actions: worker.actions(entry), + } + } + if len(response.Entries) != 0 { + response.Selected = response.Entries[0].ID + for _, entry := range response.Entries { + if entry.ID == selected { + response.Selected = selected + break + } + } + } + return response +} + +func (worker *notificationListWorker) listEntries() []notification.Entry { + history := worker.presenter.Entries() + entries := make([]notification.Entry, 0, len(history)) + for index := len(history) - 1; index >= 0; index-- { + entry := history[index] + entry.ID = strconv.Itoa(index) + entries = append(entries, entry) + } + return entries +} + +func (worker *notificationListWorker) selectionAfterRemoval(request notificationui.Request) string { + row := 0 + if request.Row != nil { + row = *request.Row + } + entries := worker.listEntries() + if len(entries) == 0 || row < 0 { + return "" + } + if row >= len(entries) { + row = len(entries) - 1 + } + return entries[row].ID +} + +func (worker *notificationListWorker) actions(entry notification.Entry) []notificationui.Action { + actions := worker.presenter.ActionsForEntry(entry) + keys := make([]string, 0, len(actions)) + for key := range actions { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]notificationui.Action, 0, len(keys)) + for _, key := range keys { + result = append(result, notificationui.Action{Key: key, Label: actions[key]}) + } + return result +} diff --git a/internal/app/object_review_events_test.go b/internal/app/object_review_events_test.go new file mode 100644 index 0000000..d99b6ff --- /dev/null +++ b/internal/app/object_review_events_test.go @@ -0,0 +1,228 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) { + speech := &fakeSpeechQueue{ + spoken: make(chan string, 2), + interrupt: make(chan struct{}, 1), + } + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + reviewRequests := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + options := presentation.DefaultOptions() + options.EnableSound = true + options.PlaySoundForRole = true + options.RoleSoundPresentation = "sound_only" + go func() { + completed <- processEventsWithReview( + ctx, + slog.New(slog.DiscardHandler), + speech, + adapter, + reviewRequests, + 0, + options, + ) + }() + + adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.NewSnapshot( + "/accessible/example", "Example", "button", "Runs the example", "", + )) + select { + case text := <-speech.spoken: + if text != "Example, Runs the example" { + t.Fatalf("focus speech = %q", text) + } + case <-time.After(time.Second): + t.Fatal("focus was not presented") + } + reviewRequests <- struct{}{} + select { + case <-speech.interrupt: + case <-time.After(time.Second): + t.Fatal("where am I did not interrupt stale speech") + } + select { + case text := <-speech.spoken: + if text != "Example, button, Runs the example" { + t.Fatalf("where am I speech = %q", text) + } + case <-time.After(time.Second): + t.Fatal("where am I was not presented") + } + cancel() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + reviewRequests := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + go func() { + completed <- processEventsWithReview( + ctx, + slog.New(slog.DiscardHandler), + speech, + adapter, + reviewRequests, + 0, + ) + }() + reviewRequests <- struct{}{} + select { + case text := <-speech.spoken: + if text != "Could not find current location." { + t.Fatalf("where am I without focus = %q", text) + } + case <-time.After(time.Second): + t.Fatal("missing-focus message was not presented") + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestProcessEventsSynchronizesAndPresentsObjectReview(t *testing.T) { + rootID := atspi.NewObjectID(":1.42", "/root") + firstID := atspi.NewObjectID(":1.42", "/first") + secondID := atspi.NewObjectID(":1.42", "/second") + root := atspi.NewSnapshot("/root", "Window", "frame", "", "") + root.BusName = ":1.42" + first := atspi.NewSnapshot("/first", "First", "push button", "", "") + first.BusName = ":1.42" + second := atspi.NewSnapshot("/second", "Second", "check box", "", "") + second.BusName = ":1.42" + second.Checked = true + tree, err := atspi.NewAccessibleTree(rootID, []atspi.TreeNode{ + {Snapshot: root, Children: []atspi.ObjectID{firstID, secondID}}, + {Snapshot: first}, + {Snapshot: second}, + }) + if err != nil { + t.Fatal(err) + } + adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree} + objectReview, err := review.NewWorker(adapter, time.Second) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := objectReview.Start(ctx); err != nil { + t.Fatal(err) + } + speech := &fakeSpeechQueue{spoken: make(chan string, 3), interrupt: make(chan struct{}, 2)} + completed := make(chan error, 1) + go func() { + completed <- processEventsWithObjectReview( + ctx, + slog.New(slog.DiscardHandler), + speech, + adapter, + nil, + objectReview, + 0, + ) + }() + + adapter.events <- atspi.NewEvent(atspi.FocusEvent, first) + select { + case text := <-speech.spoken: + if text != "First, push button" { + t.Fatalf("focus speech = %q", text) + } + case <-time.After(time.Second): + t.Fatal("focus was not presented") + } + if err := objectReview.Move(review.NextSibling); err != nil { + t.Fatal(err) + } + select { + case text := <-speech.spoken: + if text != "Second, check box, checked" { + t.Fatalf("object review speech = %q", text) + } + case <-time.After(time.Second): + t.Fatal("object review movement was not presented") + } + if err := objectReview.Move(review.FirstChild); err != nil { + t.Fatal(err) + } + select { + case text := <-speech.spoken: + if text != "No children." { + t.Fatalf("object review boundary = %q", text) + } + case <-time.After(time.Second): + t.Fatal("object review boundary was not presented") + } + + cancel() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } + if err := objectReview.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestObjectReviewBoundaryMessagesMatchPython(t *testing.T) { + tests := map[review.Direction]string{ + review.Parent: "No parent.", + review.FirstChild: "No children.", + review.PreviousSibling: "No previous.", + review.NextSibling: "No next.", + } + for direction, expected := range tests { + if got := objectReviewBoundaryMessage(direction); got != expected { + t.Fatalf("direction %d = %q, want %q", direction, got, expected) + } + } +} diff --git a/internal/app/ocr_activate.go b/internal/app/ocr_activate.go new file mode 100644 index 0000000..d43e224 --- /dev/null +++ b/internal/app/ocr_activate.go @@ -0,0 +1,242 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bytes" + "context" + "errors" + "image" + "os/exec" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +type ocrActivateRequest struct { + job ocrJob + word ocr.Word + capture ocr.Capture +} + +type ocrDialogConfirmer struct{ executable string } + +func (helper ocrDialogConfirmer) Confirm(ctx context.Context, word string) (bool, error) { + if len(word) == 0 || len(word) > 4096 || strings.ContainsRune(word, 0) { + return false, errors.New("invalid OCR word") + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, helper.executable, "--ocr-confirm") + command.Stdin = strings.NewReader(word) + var output bytes.Buffer + command.Stdout = &output + if err := command.Run(); err != nil { + return false, err + } + if output.String() != "yes\n" && output.String() != "cancel\n" { + return false, errors.New("invalid OCR confirmation") + } + return output.String() == "yes\n", nil +} + +func (session *ocrSession) activate_locked() { + if session.cursor == nil || session.refreshing || session.refreshFailed || session.activating || session.clicker == nil || session.activateRequests == nil || + !session.current_locked(session.job) || session.job.kind == ocr.ClipboardSource { + session.status_locked("OCR activation unavailable; refresh the screen result") + return + } + word, ok := session.cursor.CurrentWord() + if !ok { + session.status_locked("Move to a recognized word to activate it") + return + } + if _, ok = session.pixels.Transform.ScreenRectangle(word.Bounds); !ok { + session.status_locked("OCR word has no screen position") + return + } + if session.autoTimer != nil { + session.autoTimer.Stop() + session.autoTimer = nil + } + session.activating = true + session.activationInterrupted = false + job := session.job + job.source = session.source + session.activateRequests <- ocrActivateRequest{job: job, word: word, capture: session.pixels} +} + +func (session *ocrSession) activation_current(request ocrActivateRequest) bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.activating && !session.activationInterrupted && session.current_locked(request.job) +} + +func (session *ocrSession) activate(request ocrActivateRequest) { + if !session.activation_current(request) { + session.finish_activation(request, true, "OCR source changed") + return + } + ctx, cancel := context.WithTimeout(request.job.ctx, 35*time.Second) + defer cancel() + if err := session.keys.SetOCRReview(ctx, false); err != nil { + session.mu.Lock() + current := session.activating && session.generation == request.job.generation && session.cancel != nil + if current { + session.retire_locked() + } + session.mu.Unlock() + if current && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + select { + case session.failures <- err: + default: + } + } + if current { + session.release_input() + } + return + } + if session.settings().OCR.ConfirmClick { + if session.confirmer == nil { + session.finish_activation(request, false, "OCR confirmation unavailable") + return + } + confirmed, err := session.confirmer.Confirm(ctx, request.word.Text) + session.wait_activation_source(ctx, request) + if err != nil || !confirmed { + session.finish_activation(request, false, "OCR activation cancelled") + return + } + } + if !session.activation_current(request) { + session.finish_activation(request, true, "OCR source changed") + return + } + if err := session.validate_activation(ctx, request); err != nil { + session.finish_activation(request, true, "OCR source changed; refresh before activation") + return + } + rectangle, ok := request.capture.Transform.ScreenRectangle(request.word.Bounds) + if !ok { + session.finish_activation(request, true, "OCR word has no screen position") + return + } + point := image.Pt(rectangle.Min.X+(rectangle.Dx()-1)/2, rectangle.Min.Y+(rectangle.Dy()-1)/2) + session.mu.Lock() + if !session.activating || session.activationInterrupted || !session.current_locked(request.job) { + session.mu.Unlock() + session.finish_activation(request, true, "OCR source changed") + return + } + session.retire_locked() + session.mu.Unlock() + clickCtx, clickCancel := context.WithTimeout(context.Background(), time.Second) + defer clickCancel() + if err := session.clicker.ClickOCR(clickCtx, request.job.foreground.Client.Window, request.capture.RootBounds, point); err != nil { + session.mu.Lock() + session.status_locked("OCR click unavailable") + session.mu.Unlock() + } +} + +func (session *ocrSession) wait_activation_source(ctx context.Context, request ocrActivateRequest) { + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for !session.activation_current(request) { + select { + case <-ctx.Done(): + return + case <-deadline.C: + return + case <-ticker.C: + } + } +} + +func (session *ocrSession) finish_activation(request ocrActivateRequest, stale bool, message string) { + session.mu.Lock() + if !session.activating || session.generation != request.job.generation || request.job.ctx.Err() != nil { + session.mu.Unlock() + return + } + if session.activationInterrupted || !session.current_locked(request.job) { + session.retire_locked() + session.status_locked("OCR source changed") + session.mu.Unlock() + return + } + session.mu.Unlock() + // Re-enter only in the original client. Native entry waits for complete + // dialog chord release and refuses changed foreground ownership. + ctx, cancel := context.WithTimeout(request.job.ctx, 2*time.Second) + err := session.keys.SetOCRContext(ctx, request.job.foreground.Client.Window, false) + cancel() + session.mu.Lock() + defer session.mu.Unlock() + if !session.activating || session.activationInterrupted || !session.current_locked(request.job) { + session.retire_locked() + return + } + if err != nil { + session.retire_locked() + session.status_locked("OCR review unavailable") + return + } + session.activating = false + if stale { + session.refreshFailed = true + } else { + session.schedule_refresh_locked() + } + session.status_locked(message) +} + +func (session *ocrSession) validate_activation(ctx context.Context, request ocrActivateRequest) error { + if request.capture.Pixels == nil { + return ocr.ErrSourceChanged + } + if request.job.kind == ocr.ObjectSource { + var zone *atspi.Rectangle + if request.job.source.HasZone { + zone = &request.job.source.Zone + } + geometry, err := session.geometry.CaptureOCRGeometry(ctx, request.job.source.Object, zone) + if err != nil || geometry.Foreground != request.job.foreground || geometry.ScreenBounds != request.capture.Request.Bounds { + return ocr.ErrSourceChanged + } + } + fresh, err := session.capture.Capture(ctx, request.capture.Request) + if err != nil || fresh.SourceWindow != request.capture.SourceWindow || fresh.RootBounds != request.capture.RootBounds || + fresh.SourceBounds != request.capture.SourceBounds || fresh.ScreenBounds != request.capture.ScreenBounds || + !equalOCRPixels(ctx, fresh.Pixels, request.capture.Pixels) { + return ocr.ErrSourceChanged + } + return nil +} + +func equalOCRPixels(ctx context.Context, first, second image.Image) bool { + if first == nil || second == nil || first.Bounds() != second.Bounds() { + return false + } + bounds := first.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + if y%32 == 0 && ctx.Err() != nil { + return false + } + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r1, g1, b1, a1 := first.At(x, y).RGBA() + r2, g2, b2, a2 := second.At(x, y).RGBA() + if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 { + return false + } + } + } + return ctx.Err() == nil +} diff --git a/internal/app/ocr_commands.go b/internal/app/ocr_commands.go new file mode 100644 index 0000000..63f0183 --- /dev/null +++ b/internal/app/ocr_commands.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +func ocr_cursor_command(command input.Command) (ocr.Unit, int) { + switch command { + case input.OCRPreviousCharacter, input.ReviewPreviousCharacter: + return ocr.Character, -1 + case input.OCRNextCharacter, input.ReviewNextCharacter: + return ocr.Character, 1 + case input.ReviewCurrentCharacter, input.ReviewPhoneticCharacter: + return ocr.Character, 0 + case input.OCRPreviousWord, input.ReviewPreviousWord: + return ocr.WordUnit, -1 + case input.OCRNextWord, input.ReviewNextWord: + return ocr.WordUnit, 1 + case input.ReviewCurrentWord, input.ReviewSpellWord, input.ReviewPhoneticWord: + return ocr.WordUnit, 0 + case input.OCRPreviousLine, input.ReviewPreviousLine: + return ocr.LineUnit, -1 + case input.OCRNextLine, input.ReviewNextLine: + return ocr.LineUnit, 1 + case input.OCRPreviousPage: + return ocr.PageUnit, -1 + case input.OCRNextPage: + return ocr.PageUnit, 1 + case input.ReviewCurrentLine, input.WhereAmI: + return ocr.LineUnit, 0 + case input.OCRLineStart: + return ocr.LineStart, 0 + case input.OCRLineEnd, input.ReviewLineEnd: + return ocr.LineEnd, 0 + case input.OCRDocumentStart, input.ReviewWindowStart: + return ocr.DocumentStart, 0 + case input.OCRDocumentEnd, input.ReviewWindowEnd: + return ocr.DocumentEnd, 0 + } + return 0, 0 +} + +// Handle runs on the command consumer and never waits for native input changes. +func (session *ocrSession) Handle(event input.CommandEvent) bool { + if session == nil { + return false + } + session.mu.Lock() + defer session.mu.Unlock() + if kind, ok := ocr_source_command(event.Command); ok { + session.request_locked(kind, event.Timestamp) + return true + } + if session.activating { + return strings.HasPrefix(string(event.Command), "ocr-") + } + if session.cancel == nil { + return strings.HasPrefix(string(event.Command), "ocr-") + } + if !session.current_locked(session.job) { + session.retire_locked() + return strings.HasPrefix(string(event.Command), "ocr-") + } + if event.Timestamp != 0 && (session.cutoff == 0 || int32(event.Timestamp-session.cutoff) > 0) { + session.cutoff = event.Timestamp + } + if session.cursor != nil { + from, to := session.selection_locked() + session.logger.Debug("OCR review command", "command", event.Command, "generation", session.generation, + "focus_path", session.focus.Path, "source_path", session.source.Object.Path, + "offset", session.cursor.Offset(), "selection_start", from, "selection_end", to) + } + switch event.Command { + case input.OCRDismiss, input.OCRDismissAndPass: + session.retire_locked() + if event.Command == input.OCRDismiss { + session.status_locked("OCR dismissed") + } + return true + case input.OCRSelectAll: + if session.cursor == nil { + return true + } + session.selectionAnchor = 0 + position, _ := session.cursor.Set(session.cursor.Length()) + session.speech.Enqueue(speechRequest{Text: "OCR text selected", Role: "system", Cause: "ocr-selection", context: session.job.ctx}) + frame := session.frame_locked(position) + if session.display != nil { + session.display.EnqueueFrame(frame) + } + session.logger.Debug("OCR selection presentation", "command", event.Command, "offset", position.Offset, + "selection_start", 0, "selection_end", position.Offset, "speech_text", "OCR text selected", + "braille_text", frame.Text, "braille_cursor", frame.Cursor) + return true + case input.OCRCopySelection: + session.copy_selection_locked() + return true + case input.OCRActivate: + session.activate_locked() + return true + case input.OCRRefresh: + session.refresh_locked(false) + return true + case input.ReadAll: + session.read_all_locked(event.Timestamp) + return true + } + if unit, step := ocr_cursor_command(event.Command); unit != 0 { + if session.cursor == nil { + return true + } + moves := step != 0 || unit >= ocr.LineStart + oldStart, oldEnd := session.selection_locked() + if moves { + previous := session.cursor.Offset() + if event.Chord.Modifiers&1 != 0 { + if session.selectionAnchor < 0 { + session.selectionAnchor = previous + } + } else { + session.selectionAnchor = -1 + } + } + position := session.cursor.Move(unit, step) + if moves && event.Chord.Modifiers&1 != 0 { + if message := session.selection_change_locked(oldStart, oldEnd); message != "" { + session.present_request_locked(position, unit, speechRequest{Text: message, Role: "text", Cause: "ocr-selection", context: session.job.ctx}) + return true + } + } + if step != 0 && !position.Moved { + session.present_request_locked(position, unit, speechRequest{Text: "OCR text boundary", Role: "system", Cause: "ocr-status", context: session.job.ctx}) + return true + } + session.present_spelling_locked(position, unit, flat_review_speech(event.Command)) + return true + } + // Ordinary reader commands restore their existing source/routing first. + session.retire_locked() + return false +} + +// Input sees XRecord observations independently of grabbed command dispatch. +// Match complete chords, and protect delayed initiating observations by X time. +// Every unclaimed press retires the session immediately, including Alt+Tab. +func (session *ocrSession) Input(event input.Event) { + if session == nil { + return + } + session.mu.Lock() + defer session.mu.Unlock() + if session.activating { + return + } + if event.Pressed { + session.held[event.Name] = true + } else { + delete(session.held, event.Name) + return + } + if session.cancel == nil { + return + } + session.logger.Debug("OCR observed input", "key", event.Name, "timestamp", event.Timestamp, "cutoff", session.cutoff) + if event.Timestamp != 0 && session.cutoff != 0 && int32(event.Timestamp-session.cutoff) <= 0 { + return + } + settings := session.settings() + symbolName := event.Name + // XRecord uses Xlib's keypad names; shortcut labels use the page names. + switch symbolName { + case "KP_Next": + symbolName = "KP_Page_Down" + case "KP_Prior": + symbolName = "KP_Page_Up" + } + chord, err := input.ParseCommandChord(symbolName) + for name := range session.held { + switch name { + case "Shift_L", "Shift_R": + chord.Modifiers |= 1 + case "Control_L", "Control_R": + chord.Modifiers |= 4 + case "Alt_L", "Alt_R", "Meta_L", "Meta_R", "Super_L", "Super_R": + session.retire_locked() + return + } + for _, modifier := range settings.CthulhuModifierKeys { + if modifier == name { + chord.CthulhuModifier = true + } + } + } + if event.Kind == input.KeyModifier { + return + } + for _, modifier := range settings.CthulhuModifierKeys { + if modifier == event.Name { + return + } + } + if err != nil { + session.retire_locked() + return + } + bindings, err := input.ResolveCommandBindings(input.KeyboardLayout(settings.KeyboardLayout), false, settings.KeyBindings) + if err != nil { + session.retire_locked() + return + } + if session.cursor == nil { + bindings = input.OCRPendingBindings(bindings) + } else { + bindings = input.OCRCommandBindings(bindings) + } + if bindings[chord] == "" { + session.logger.Debug("OCR dismissed by unclaimed input", "chord", input.FormatCommandChord(chord)) + session.retire_locked() + } +} diff --git a/internal/app/ocr_copy.go b/internal/app/ocr_copy.go new file mode 100644 index 0000000..321d30d --- /dev/null +++ b/internal/app/ocr_copy.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "os/exec" + "strings" + "time" +) + +type ocrTextCopier interface { + Copy(context.Context, string) error +} + +// xclip becomes the X11 selection owner after its short-lived parent exits. +// It releases ownership when another application replaces the clipboard. +type xclipOCRCopier struct{} + +func (xclipOCRCopier) Copy(ctx context.Context, value string) error { + if value == "" { + return errors.New("empty OCR selection") + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in") + command.Stdin = strings.NewReader(value) + return command.Run() +} + +type ocrCopyRequest struct { + ctx context.Context + generation uint64 + text string + automatic bool +} + +func (session *ocrSession) copy_selection_locked() { + if session.cursor == nil || session.selectionAnchor < 0 || session.selectionAnchor == session.cursor.Offset() { + session.status_locked("No OCR text selected") + return + } + start, end := min(session.selectionAnchor, session.cursor.Offset()), max(session.selectionAnchor, session.cursor.Offset()) + value, ok := session.cursor.Slice(start, end) + if !ok || value == "" || session.copier == nil || session.copyRequests == nil { + session.status_locked("OCR copy unavailable") + return + } + request := ocrCopyRequest{ctx: session.job.ctx, generation: session.generation, text: value} + select { + case session.copyRequests <- request: + default: + // Keep the newest explicit copy request without blocking the input loop. + select { + case <-session.copyRequests: + default: + } + session.copyRequests <- request + } +} + +func (session *ocrSession) copy(request ocrCopyRequest) { + if request.ctx.Err() != nil { + return + } + err := session.copier.Copy(request.ctx, request.text) + session.mu.Lock() + defer session.mu.Unlock() + if request.ctx.Err() != nil || request.generation != session.generation || !session.current_locked(session.job) { + return + } + // A successful optional copy must not cancel initial Say All or replace its + // routable braille frame. Explicit copies retain their normal confirmation. + if request.automatic && err == nil && session.job.autoReadAll { + return + } + message := "OCR text copied to clipboard" + if err != nil { + message = "OCR copy unavailable" + session.logger.Debug("OCR copy failed", "error", err) + } + session.status_locked(message) +} diff --git a/internal/app/ocr_copy_test.go b/internal/app/ocr_copy_test.go new file mode 100644 index 0000000..a307304 --- /dev/null +++ b/internal/app/ocr_copy_test.go @@ -0,0 +1,28 @@ +package app + +import ( + "context" + "os" + "os/exec" + "testing" + "time" +) + +func TestNativeOCRClipboardCopy(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR_COPY") != "1" { + t.Skip("requires a private X11 display and xclip") + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + want := "é猫 OCR\nsecond line" + if err := (xclipOCRCopier{}).Copy(ctx, want); err != nil { + t.Fatal(err) + } + got, err := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out").Output() + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Fatalf("clipboard = %q, want %q", got, want) + } +} diff --git a/internal/app/ocr_native_test.go b/internal/app/ocr_native_test.go new file mode 100644 index 0000000..f2cae35 --- /dev/null +++ b/internal/app/ocr_native_test.go @@ -0,0 +1,276 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// This joins the actual reader loops, AT-SPI/review, XRecord, native grabs, +// capture helper and installed engine, with recording output sinks. It does +// not run an installed reader or establish audible/physical braille acceptance. +func TestNativeOCRSession(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR_SESSION") != "1" { + t.Skip("requires private Xorg/AT-SPI session and installed Tesseract") + } + executable := os.Getenv("CTHULHU_OCR_CAPTURE_BINARY") + if executable == "" { + t.Fatal("set CTHULHU_OCR_CAPTURE_BINARY to the temporary reader binary") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + logger := slog.New(slog.DiscardHandler) + adapter := atspi.New() + keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + settings := config.DefaultSettings() + settings.EnableKeyEcho = false + settings.KeyBindings = []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.OCRClipboard, Keys: []string{"Cthulhu+F8"}}} + if err := keys.SetKeyBindings(ctx, settings.KeyBindings); err != nil { + t.Fatal(err) + } + listener := xorg.New() + objectReview, err := review.NewWorker(adapter, time.Second) + if err != nil { + t.Fatal(err) + } + output := &ocrTestOutput{speech: make(chan speechRequest, 256), frames: make(chan braille.Frame, 256)} + gate := newFocusGate(keys) + live := &liveSettings{} + live.Store(settings) + session := new_ocr_session(logger, keys, adapter, objectReview, output, nil, output, gate, live) + session.capture = ocr.NewCapturer(executable) + gate.ocr = session + for _, service := range []interface { + Start(context.Context) error + Stop(context.Context) error + }{keys, adapter, objectReview, listener, session} { + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + defer service.Stop(context.Background()) + } + commandDone := make(chan error, 1) + eventDone := make(chan error, 1) + inputDone := make(chan struct{}) + go func() { + commandDone <- (commandRuntime{logger: logger, commands: keys, focusGate: gate, speechOutput: output, objectReview: objectReview, ocr: session, stopRun: cancel}).run(ctx) + }() + go func() { + eventDone <- processEventsWithOCR(ctx, logger, outputQueue{speech: output, braille: output, live: live}, adapter, nil, objectReview, time.Millisecond, live, listener, gate, nil, session) + }() + go func() { + defer close(inputDone) + processInputEvents(ctx, logger, output, listener, inputEchoSettingsFromConfig(settings), live, keys, session) + }() + defer func() { + cancel() + if err := <-commandDone; err != nil { + t.Error(err) + } + if err := <-eventDone; err != nil { + t.Error(err) + } + <-inputDone + }() + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/ocr_session_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + read := func() map[string]uint64 { + t.Helper() + if !scanner.Scan() { + t.Fatal("OCR fixture did not respond") + } + var state map[string]uint64 + if err := json.Unmarshal(scanner.Bytes(), &state); err != nil { + t.Fatal(err) + } + return state + } + read() + action := func(name string) map[string]uint64 { + t.Helper() + data, _ := json.Marshal(map[string]string{"action": name}) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + state := read() + if state["focus"] != state["window"] { + t.Fatalf("OCR changed native window focus: %+v", state) + } + return state + } + key := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("key: %v %s", err, out) + } + } + defer exec.Command("xdotool", "keyup", "Insert", "Control_L", "Shift_L", "o", "Escape").Run() + wait := func(predicate func() bool) { + t.Helper() + for !predicate() { + select { + case err := <-session.failures: + t.Fatal(err) + case err := <-keys.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + } + focus := func(actionName string) { + session.mu.Lock() + previous := session.focus + session.mu.Unlock() + action(actionName) + wait(func() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.focus.Valid() && session.focus != previous + }) + } + // Observe actual focused-widget speech, not merely a valid frame identity. + for { + got := ocr_test_receive(t, output.speech) + if strings.Contains(got.Text, "Painted OCR target") { + break + } + } + wait(func() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.focus.Valid() + }) + expect := func(want string) { + t.Helper() + for { + select { + case got := <-output.speech: + t.Logf("output %s: %q", got.Cause, got.Text) + if got.Cause == "ocr-status" || got.Cause == "ocr-cursor" || got.Cause == "ocr-selection" { + if got.Text != want { + t.Fatalf("OCR speech: %+v want %q", got, want) + } + return + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + frame := func(text string, cursor int) { + t.Helper() + for { + select { + case got := <-output.frames: + if got.Text == text { + if got.Cursor != cursor || len(got.Routes) != 0 { + t.Fatalf("OCR braille: %+v", got) + } + return + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + key("Insert+o") + expect("OCR test window") + frame("OCR test window", 0) + wait(keys.OCRReviewEnabled) + key("shift+Right") + expect("O selected") + frame("OCR test window", 1) + key("shift+Left") + expect("O unselected") + frame("OCR test window", 0) + key("ctrl+Right") + expect("test") + frame("OCR test window", 4) + key("Down") + expect("Open Settings") + frame("Open Settings", 4) + action("state") + key("Escape") + expect("OCR dismissed") + wait(func() bool { return !keys.OCRReviewEnabled() }) + key("Insert+F8") + expect("OCR test window") + frame("OCR test window", 0) + action("clipboard-text") + key("Down") + expect("Open Settings") + key("ctrl+a") + expect("OCR text selected") + key("ctrl+c") + expect("OCR text copied to clipboard") + session.mu.Lock() + wantCopy, _ := session.cursor.Slice(0, session.cursor.Length()) + session.mu.Unlock() + copyResult, err := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out").Output() + if err != nil || string(copyResult) != wantCopy { + t.Fatalf("OCR clipboard copy = %q, want %q: %v", copyResult, wantCopy, err) + } + // Native focus changes within the same X11 window dismiss a ready result. + action("other") + wait(func() bool { return !keys.OCRReviewEnabled() }) + focus("password") + key("Insert+o") + expect("OCR test window") + key("Escape") + expect("OCR dismissed") + focus("painted") + key("Insert+o") + expect("OCR test window") + key("h") + wait(func() bool { return !keys.OCRReviewEnabled() }) + // No accessibility provider exists for this client. The default command, + // review, refresh and dismissal must work using only X11 ownership/pixels. + inaccessible := action("inaccessible") + wait(func() bool { return adapter.Foreground().Client.Window == inaccessible["window"] }) + key("Insert+o") + expect("OCR test window") + frame("OCR test window", 0) + wait(keys.OCRReviewEnabled) + key("Down") + expect("Open Settings") + frame("Open Settings", 0) + key("Insert+F5") + expect("OCR text unchanged") + action("state") + key("Escape") + expect("OCR dismissed") + wait(func() bool { return !keys.OCRReviewEnabled() }) +} diff --git a/internal/app/ocr_read_all.go b/internal/app/ocr_read_all.go new file mode 100644 index 0000000..676c433 --- /dev/null +++ b/internal/app/ocr_read_all.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +// read_all_locked starts ordinary paced speech from the current virtual +// offset. Progress moves only OCR's cursor and braille; native focus is intact. +func (session *ocrSession) read_all_locked(timestamp uint32) { + if session.cursor == nil || session.readAll == nil { + session.status_locked("OCR Say All is unavailable") + return + } + lines := session.cursor.LinesFrom() + if len(lines) == 0 { + session.status_locked("OCR text boundary") + return + } + job := session.job + session.selectionAnchor = -1 + ctx := session.readAll.BeginReadAll(job.ctx, timestamp) + session.readAllContext = ctx + session.readAllDone = make(chan error, 1) + parts := make([]readAllPart, 0, len(lines)) + for _, line := range lines { + offset := line.Offset + parts = append(parts, readAllPart{Text: line.Text, Began: func() { + session.mu.Lock() + defer session.mu.Unlock() + if ctx.Err() != nil || !session.current_locked(job) || session.cursor == nil { + return + } + position, ok := session.cursor.Set(offset) + if !ok { + return + } + frame := session.frame_locked(position) + if session.display != nil { + session.display.EnqueueFrame(frame) + } + session.logger.Debug("OCR Say All progress", "command", "read-all", "generation", job.generation, + "focus_path", session.focus.Path, "source_path", session.source.Object.Path, "offset", position.Offset, + "selection_start", -1, "selection_end", -1, "speech_text", line.Text, + "braille_text", frame.Text, "braille_cursor", frame.Cursor) + }}) + } + session.speech.Enqueue(speechRequest{ReadAll: parts, Role: "text", Cause: "read-all", context: ctx, result: session.readAllDone}) +} diff --git a/internal/app/ocr_reading_test.go b/internal/app/ocr_reading_test.go new file mode 100644 index 0000000..1e6963c --- /dev/null +++ b/internal/app/ocr_reading_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "testing" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +func TestOCRAutomaticReadAllOnceWithCopyAndRefresh(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + reader := &ocrTestReadAll{} + copier := &ocrTestCopier{copied: make(chan string, 1)} + session.readAll, session.copier = reader, copier + settings := session.settings() + settings.OCR.AutoReadAll, settings.OCR.CopyFirstResult = true, true + session.live = &liveSettings{} + session.live.Store(settings) + session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) + ocr_test_receive(t, engine.calls) + // A pending job must keep the initiating preferences. + settings.OCR.AutoReadAll, settings.OCR.CopyFirstResult = false, false + session.live.Store(settings) + engine.results <- ocr.Result{Text: "é one\n猫 two"} + request := ocr_test_receive(t, output.speech) + if request.Cause != "read-all" || len(request.ReadAll) != 2 || request.ReadAll[0].Text != "é one" || reader.timestamp != 100 { + t.Fatalf("initial reading: %+v", request) + } + frame := ocr_test_receive(t, output.frames) + if frame.Text != "é one" || frame.Cursor != 0 || frame.Virtual == nil { + t.Fatalf("initial braille: %+v", frame) + } + if text := ocr_test_receive(t, copier.copied); text != "é one\n猫 two" { + t.Fatal(text) + } + request.ReadAll[1].Began() + frame = ocr_test_receive(t, output.frames) + if frame.Text != "猫 two" || frame.Virtual.SourceStart != 6 { + t.Fatalf("paced braille: %+v", frame) + } + session.Handle(input.CommandEvent{Command: input.OCRRefresh, Timestamp: 101}) + ocr_test_receive(t, engine.calls) // Worker has also finished the optional copy. + if request.context.Err() != context.Canceled { + t.Fatal("manual refresh did not cancel old reading") + } + engine.results <- ocr.Result{Text: "é one\n猫 two"} + if got := ocr_test_receive(t, output.speech); got.Text != "OCR text unchanged" || len(got.ReadAll) != 0 { + t.Fatalf("copy interrupted reading or refresh restarted it: %+v", got) + } + ocr_test_receive(t, output.frames) // Status, then current review frame. + ocr_test_receive(t, output.frames) + request.ReadAll[0].Began() + select { + case got := <-output.frames: + t.Fatalf("stale reading moved refreshed braille: %+v", got) + default: + } +} + +func TestOCRPeriodicRefreshWaitsForSayAll(t *testing.T) { + for _, interrupted := range []bool{false, true} { + t.Run(map[bool]string{false: "completed", true: "interrupted"}[interrupted], func(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + session.readAll = &ocrTestReadAll{} + settings := session.settings() + settings.OCR.AutoReadAll = true + session.live = &liveSettings{} + session.live.Store(settings) + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "first\nsecond"} + request := ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + settings.OCR.AutoRefresh = true + session.live.Store(settings) + session.mu.Lock() + session.refresh_locked(true) + if session.refreshing || request.context.Err() != nil { + session.mu.Unlock() + t.Fatal("periodic refresh truncated Say All") + } + if interrupted { + ctx, cancel := context.WithCancel(session.job.ctx) + session.readAllContext = ctx + cancel() + } else { + completeSpeechRequest(request, nil) + } + session.refresh_locked(true) + if !session.refreshing { + session.mu.Unlock() + t.Fatal("periodic refresh did not resume after reading") + } + session.mu.Unlock() + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "changed"} + if got := ocr_test_receive(t, output.speech); got.Text != "changed" || len(got.ReadAll) != 0 { + t.Fatalf("automatic refresh restarted Say All: %+v", got) + } + }) + } +} + +func TestOCRShiftSelectionAnnouncesChangedRanges(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "é猫abc\nnext"} + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + steps := []struct { + command input.Command + shift bool + speech string + offset, start, end int + }{ + {input.OCRNextCharacter, true, "é selected", 1, 0, 1}, + {input.OCRNextCharacter, true, "猫 selected", 2, 0, 2}, + {input.OCRPreviousCharacter, true, "猫 unselected", 1, 0, 1}, + {input.OCRPreviousCharacter, true, "é unselected", 0, 0, 0}, + {input.OCRPreviousCharacter, true, "OCR text boundary", 0, 0, 0}, + {input.OCRNextCharacter, false, "猫", 1, -1, -1}, + {input.OCRNextCharacter, true, "猫 selected", 2, 1, 2}, + {input.OCRLineStart, true, "猫 unselected, é selected", 0, 0, 1}, + {input.OCRLineEnd, true, "é unselected, 猫abc selected", 5, 1, 5}, + {input.OCRNextCharacter, true, "selected", 6, 1, 6}, + {input.OCRDocumentEnd, true, "next selected", 10, 1, 10}, + {input.OCRNextCharacter, false, "OCR text boundary", 10, -1, -1}, + } + for _, step := range steps { + modifiers := uint16(0) + if step.shift { + modifiers = 1 + } + session.Handle(input.CommandEvent{Command: step.command, Chord: input.CommandChord{Modifiers: modifiers}}) + if got := ocr_test_receive(t, output.speech); got.Text != step.speech { + t.Fatalf("%s shift=%v: %q, want %q", step.command, step.shift, got.Text, step.speech) + } + frame := ocr_test_receive(t, output.frames) + session.mu.Lock() + start, end := session.selection_locked() + offset := session.cursor.Offset() + session.mu.Unlock() + if offset != step.offset || start != step.start || end != step.end || frame.Virtual == nil { + t.Fatalf("%s: offset=%d selection=%d:%d frame=%+v", step.command, offset, start, end, frame) + } + for index, dots := range frame.Dots { + source := frame.Virtual.SourceStart + index + if (dots != 0) != (source >= start && source < end) { + t.Fatalf("wrong selection dots: %+v", frame) + } + } + if start == end && len(frame.Dots) != 0 { + t.Fatalf("cleared selection retained dots: %+v", frame) + } + } +} diff --git a/internal/app/ocr_refresh.go b/internal/app/ocr_refresh.go new file mode 100644 index 0000000..ab29e2f --- /dev/null +++ b/internal/app/ocr_refresh.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/ocr" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// Retain an unchanged prefix or suffix exactly. A changed middle keeps the +// nearest legal offset; identical repeated text is deliberately not guessed. +func remapOCROffset(oldText, newText string, offset int) int { + oldRunes, newRunes := []rune(oldText), []rune(newText) + offset = max(0, min(offset, len(oldRunes))) + prefix := 0 + for prefix < len(oldRunes) && prefix < len(newRunes) && oldRunes[prefix] == newRunes[prefix] { + prefix++ + } + if offset < prefix { + return offset + } + suffix := 0 + for suffix < len(oldRunes)-prefix && suffix < len(newRunes)-prefix && + oldRunes[len(oldRunes)-1-suffix] == newRunes[len(newRunes)-1-suffix] { + suffix++ + } + if offset >= len(oldRunes)-suffix { + return len(newRunes) - (len(oldRunes) - offset) + } + return min(offset, len(newRunes)) +} + +func (session *ocrSession) finish_refresh_locked(job ocrJob, interest review.Interest, capture ocr.Capture, result ocr.Result, err error) { + session.refreshing = false + if err != nil { + session.refreshFailed = true + session.status_locked("OCR refresh failed; activate after a successful refresh") + return + } + previous := session.cursor.Text() + offset := remapOCROffset(previous, result.Text, session.cursor.Offset()) + anchor := -1 + if session.selectionAnchor >= 0 { + anchor = remapOCROffset(previous, result.Text, session.selectionAnchor) + } + session.source, session.pixels = interest, capture + session.cursor = ocr.NewCursor(result) + position, _ := session.cursor.Set(offset) + session.selectionAnchor = anchor + session.refreshFailed = result.Text == "" + if result.Text == "" { + session.status_locked("No text recognized") + } else if result.Text != previous { + session.status_locked(ocrChangedAnnouncement(previous, result.Text)) + } else if !job.automatic { + session.status_locked("OCR text unchanged") + } + if session.display != nil { + session.display.EnqueueFrame(session.frame_locked(position)) + } + session.schedule_refresh_locked() +} + +func ocrChangedAnnouncement(previous, current string) string { + oldRunes, newRunes := []rune(previous), []rune(current) + index := 0 + for index < len(oldRunes) && index < len(newRunes) && oldRunes[index] == newRunes[index] { + index++ + } + if index == len(newRunes) && index < len(oldRunes) { + return "OCR text removed" + } + start, end := index, index + for start > 0 && newRunes[start-1] != '\n' { + start-- + } + for end < len(newRunes) && newRunes[end] != '\n' && end-start < 100 { + end++ + } + line := strings.TrimSpace(string(newRunes[start:end])) + if line == "" { + return "OCR text changed" + } + return line +} diff --git a/internal/app/ocr_refresh_activation_test.go b/internal/app/ocr_refresh_activation_test.go new file mode 100644 index 0000000..6299e7e --- /dev/null +++ b/internal/app/ocr_refresh_activation_test.go @@ -0,0 +1,193 @@ +package app + +import ( + "context" + "image" + "image/color" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +func TestOCRClipboardRefreshKeepsSnapshotAndCursor(t *testing.T) { + session, keys, capture, engine, output := ocr_test_session(t) + session.Handle(input.CommandEvent{Command: input.OCRClipboard, Timestamp: 100}) + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "one two", Words: []ocr.Word{{Text: "one", Span: ocr.Span{Start: 0, End: 3}}, {Text: "two", Span: ocr.Span{Start: 4, End: 7}}}} + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + session.Handle(input.CommandEvent{Command: input.OCRNextWord, Timestamp: 101}) + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + session.Handle(input.CommandEvent{Command: input.OCRRefresh, Timestamp: 102}) + ocr_test_receive(t, engine.calls) + select { + case <-capture.calls: + t.Fatal("clipboard refresh fetched a changed clipboard") + default: + } + engine.results <- ocr.Result{Text: "one three", Words: []ocr.Word{{Text: "one", Span: ocr.Span{Start: 0, End: 3}}, {Text: "three", Span: ocr.Span{Start: 4, End: 9}}}} + if got := ocr_test_receive(t, output.speech); got.Text != "one three" { + t.Fatal(got) + } + session.mu.Lock() + defer session.mu.Unlock() + if session.cursor == nil || session.cursor.Offset() != 4 || session.cursor.Text() != "one three" || session.refreshFailed { + t.Fatal("refresh lost cursor or snapshot") + } +} + +func TestOCRRefreshRetainsUnchangedSuffixAcrossInsertion(t *testing.T) { + if got := remapOCROffset("é one", "new é one", 2); got != 6 { + t.Fatalf("cursor moved to inserted text: %d", got) + } + if got := remapOCROffset("é one", "é new one", 2); got != 6 { + t.Fatalf("cursor missed original suffix: %d", got) + } + if got := remapOCROffset("old text", "old", 8); got != 3 { + t.Fatalf("deleted suffix was not clamped: %d", got) + } +} + +func TestOCRStaleActivationCannotRetireReplacement(t *testing.T) { + session, keys, _, engine, _ := ocr_test_session(t) + defer func() { engine.results <- ocr.Result{}; engine.results <- ocr.Result{} }() + session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) + ocr_test_receive(t, keys.calls) + session.mu.Lock() + old := session.job + session.mu.Unlock() + session.Handle(input.CommandEvent{Command: input.OCRDesktop, Timestamp: 101}) + session.mu.Lock() + current := session.job.generation + session.mu.Unlock() + session.activate(ocrActivateRequest{job: old}) + session.mu.Lock() + defer session.mu.Unlock() + if session.cancel == nil || session.job.generation != current { + t.Fatal("late activation retired replacement job") + } +} + +func TestOCRAutoRefreshWaitsForEachJob(t *testing.T) { + session, keys, capture, engine, output := ocr_test_session(t) + live := &liveSettings{} + settings := config.DefaultSettings() + settings.OCR.AutoRefresh = true + settings.OCR.RefreshInterval = 500 + live.Store(settings) + session.live = live + session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "ready"} + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) + select { + case <-capture.calls: + t.Fatal("automatic OCR jobs overlapped") + case <-time.After(600 * time.Millisecond): + } + engine.results <- ocr.Result{Text: "ready"} + ocr_test_receive(t, output.frames) + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) +} + +type ocrTestClicker struct{ points chan image.Point } + +func (clicker *ocrTestClicker) ClickOCR(ctx context.Context, _ uint64, _ image.Rectangle, point image.Point) error { + clicker.points <- point + return ctx.Err() +} + +type ocrTestConfirmer struct { + answer bool + called chan string +} + +func (confirm *ocrTestConfirmer) Confirm(_ context.Context, word string) (bool, error) { + confirm.called <- word + return confirm.answer, nil +} + +func TestOCRActivationChecksFreshPixelsBeforeClick(t *testing.T) { + for _, test := range []struct { + name string + command input.Command + changed bool + }{ + {"object/unchanged", input.OCRObject, false}, + {"object/changed", input.OCRObject, true}, + {"inaccessible-window/unchanged", input.OCRWindow, false}, + {"inaccessible-window/changed", input.OCRWindow, true}, + } { + t.Run(test.name, func(t *testing.T) { + session, keys, capture, engine, output := ocr_test_session(t) + if test.command == input.OCRWindow { + session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{} + session.geometry, session.interest = nil, nil + } + clicker := &ocrTestClicker{points: make(chan image.Point, 1)} + confirmer := &ocrTestConfirmer{answer: true, called: make(chan string, 1)} + session.clicker, session.confirmer = clicker, confirmer + paint := color.RGBA{R: 20, A: 255} + capture.produce = func(ctx context.Context, request ocr.CaptureRequest) (ocr.Capture, error) { + pixels := image.NewRGBA(image.Rect(0, 0, 100, 50)) + for index := range pixels.Pix { + pixels.Pix[index] = 20 + } + pixels.SetRGBA(0, 0, paint) + bounds := image.Rect(10, 20, 110, 70) + return ocr.Capture{CaptureHeader: ocr.CaptureHeader{Request: request, RootBounds: image.Rect(0, 0, 200, 200), SourceBounds: bounds, ScreenBounds: bounds, SourceWindow: request.Foreground}, Pixels: pixels, + Transform: ocr.Transform{ImageContent: image.Rect(0, 0, 100, 50), ScreenBounds: bounds, HasScreenMapping: true}}, ctx.Err() + } + session.Handle(input.CommandEvent{Command: test.command, Timestamp: 100}) + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "go", Width: 100, Height: 50, Words: []ocr.Word{{Text: "go", Bounds: image.Rect(10, 10, 30, 20), Span: ocr.Span{Start: 0, End: 2}}}} + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + if test.changed { + paint = color.RGBA{R: 30, A: 255} + } + session.Handle(input.CommandEvent{Command: input.OCRActivate, Timestamp: 101}) + if got := ocr_test_receive(t, confirmer.called); got != "go" { + t.Fatal(got) + } + ocr_test_receive(t, capture.calls) + if test.changed { + if got := ocr_test_receive(t, output.speech); got.Text != "OCR source changed; refresh before activation" { + t.Fatal(got) + } + select { + case <-clicker.points: + t.Fatal("clicked changed image") + default: + } + } else { + select { + case point := <-clicker.points: + if point != image.Pt(29, 34) { + t.Fatal(point) + } + case <-time.After(3 * time.Second): + t.Fatal("valid word was not clicked") + } + } + }) + } +} diff --git a/internal/app/ocr_selection.go b/internal/app/ocr_selection.go new file mode 100644 index 0000000..ce4d709 --- /dev/null +++ b/internal/app/ocr_selection.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "fmt" + "strings" +) + +// Selection is owned by the session's anchor and cursor. Compare ranges in the +// current document without retaining text or inventing a native object identity. +func (session *ocrSession) selection_change_locked(oldStart, oldEnd int) string { + start, end := session.selection_locked() + var changes []string + fragment := func(from, to int, selected bool) { + if from < 0 || to <= from { + return + } + message := "unselected" + if selected { + message = "selected" + } + if to-from <= lineSelectionFragmentLimit { + value, _ := session.cursor.Slice(from, to) + value = strings.Join(strings.Fields(value), " ") + if value != "" { + if selected { + message = fmt.Sprintf("%s selected", value) + } else { + message = fmt.Sprintf("%s unselected", value) + } + } + } + changes = append(changes, message) + } + if oldEnd > oldStart { + if end <= start { + fragment(oldStart, oldEnd, false) + } else { + fragment(oldStart, min(oldEnd, start), false) + fragment(max(oldStart, end), oldEnd, false) + } + } + if end > start { + if oldEnd <= oldStart { + fragment(start, end, true) + } else { + fragment(start, min(end, oldStart), true) + fragment(max(start, oldEnd), end, true) + } + } + return strings.Join(changes, ", ") +} diff --git a/internal/app/ocr_session.go b/internal/app/ocr_session.go new file mode 100644 index 0000000..eb577ec --- /dev/null +++ b/internal/app/ocr_session.go @@ -0,0 +1,690 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "image" + "log/slog" + "os" + "sync" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" + "git.stormux.org/storm/cthulhu/internal/review" +) + +type ocrKeyOwner interface { + SetOCRReview(context.Context, bool) error + SetOCRContext(context.Context, uint64, bool) error +} +type ocrClicker interface { + ClickOCR(context.Context, uint64, image.Rectangle, image.Point) error +} +type ocrConfirmer interface { + Confirm(context.Context, string) (bool, error) +} +type ocrGeometrySource interface { + CaptureOCRGeometry(context.Context, atspi.ObjectID, *atspi.Rectangle) (atspi.OCRGeometry, error) +} +type ocrInterestSource interface { + ObjectOfInterest(context.Context) (review.Interest, error) +} +type ocrCaptureSource interface { + Capture(context.Context, ocr.CaptureRequest) (ocr.Capture, error) +} +type ocrRecognizer interface { + Recognize(context.Context, image.Image, ocr.Options) (ocr.Result, error) +} +type ocrReadAllStarter interface { + BeginReadAll(context.Context, uint32) context.Context +} + +type ocrJob struct { + window atspi.ObjectID + ctx context.Context + generation uint64 + focus atspi.ObjectID + foreground atspi.ForegroundState + kind ocr.SourceKind + options ocr.Options + source review.Interest + snapshot ocr.Capture + refresh bool + automatic bool + autoReadAll bool + copyFirstResult bool + timestamp uint32 +} + +// ocrSession owns source/job/result generations and the virtual cursor under mu. +// A single worker serializes capture, recognition and acknowledged input changes. +// Retirement cancels synchronously; the worker joins children and releases native +// input before admitting the next job. No native work runs on an input/event loop. +type ocrSession struct { + window atspi.ObjectID + mu sync.Mutex + logger *slog.Logger + keys ocrKeyOwner + geometry ocrGeometrySource + interest ocrInterestSource + foreground foregroundMonitor + capture ocrCaptureSource + engine ocrRecognizer + copier ocrTextCopier + copyRequests chan ocrCopyRequest + activateRequests chan ocrActivateRequest + clicker ocrClicker + confirmer ocrConfirmer + speech speechEnqueuer + readAll ocrReadAllStarter + readAllContext context.Context // Current speech request; cleared on retirement/refresh. + readAllDone chan error + display brailleFrameEnqueuer + gate *focusGate + live *liveSettings + ctx context.Context + stop context.CancelFunc + done chan struct{} + wake chan struct{} + failures chan error + focus atspi.ObjectID + focusForeground uint64 + job ocrJob + cancel context.CancelFunc + generation uint64 + cursor *ocr.Cursor + selectionAnchor int // -1 means no active virtual selection. + source review.Interest + pixels ocr.Capture + held map[string]bool + cutoff uint32 + refreshing bool + refreshFailed bool + autoTimer *time.Timer + activating bool + activationInterrupted bool +} + +func new_ocr_session(logger *slog.Logger, commands commandListener, adapter Adapter, interest *review.Worker, speech speechEnqueuer, readAll ocrReadAllStarter, display brailleFrameEnqueuer, gate *focusGate, live *liveSettings) *ocrSession { + executable, _ := os.Executable() + session := &ocrSession{logger: logger, interest: interest, speech: gatedSpeech{speech: speech, gate: gate}, readAll: readAll, display: display, gate: gate, live: live, + capture: ocr.NewCapturer(executable), engine: ocr.NewEngine(), copier: xclipOCRCopier{}, copyRequests: make(chan ocrCopyRequest, 1), + activateRequests: make(chan ocrActivateRequest, 1), confirmer: ocrDialogConfirmer{executable: executable}, + wake: make(chan struct{}, 1), failures: make(chan error, 1), held: make(map[string]bool), selectionAnchor: -1} + session.keys, _ = commands.(ocrKeyOwner) + session.clicker, _ = commands.(ocrClicker) + session.geometry, _ = adapter.(ocrGeometrySource) + session.foreground, _ = adapter.(foregroundMonitor) + return session +} + +func (session *ocrSession) Start(ctx context.Context) error { + session.mu.Lock() + session.ctx, session.stop = context.WithCancel(ctx) + session.done = make(chan struct{}) + session.mu.Unlock() + go session.run() + return nil +} + +func (session *ocrSession) Stop(ctx context.Context) error { + session.Retire() + session.mu.Lock() + stop, done := session.stop, session.done + session.mu.Unlock() + if stop == nil { + return nil + } + stop() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (session *ocrSession) signal() { + select { + case session.wake <- struct{}{}: + default: + } +} + +func (session *ocrSession) retire_locked() { + if session.autoTimer != nil { + session.autoTimer.Stop() + session.autoTimer = nil + } + if session.cancel == nil { + return + } + session.logger.Debug("OCR session retired", "generation", session.generation) + session.cancel() + session.cancel = nil + session.generation++ + session.cursor = nil + session.selectionAnchor = -1 + session.source = review.Interest{} + session.pixels = ocr.Capture{} + session.refreshing = false + session.refreshFailed = false + session.readAllContext, session.readAllDone = nil, nil + session.activating = false + session.activationInterrupted = false + if session.display != nil { + session.display.EnqueueFrame(braille.Frame{Cursor: -1}) + } + session.signal() +} + +func (session *ocrSession) Retire() { + if session == nil { + return + } + session.mu.Lock() + defer session.mu.Unlock() + session.retire_locked() +} + +func (session *ocrSession) Secure() { + if session == nil { + return + } + session.mu.Lock() + defer session.mu.Unlock() + session.retire_locked() + session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{} + session.focusForeground = 0 + clear(session.held) +} + +func ocr_source_command(command input.Command) (ocr.SourceKind, bool) { + switch command { + case input.OCRObject: + return ocr.ObjectSource, true + case input.OCRWindow: + return ocr.WindowSource, true + case input.OCRDesktop: + return ocr.DesktopSource, true + case input.OCRClipboard: + return ocr.ClipboardSource, true + } + return "", false +} + +func (session *ocrSession) request_locked(kind ocr.SourceKind, timestamp uint32) { + session.retire_locked() + session.cutoff = timestamp + if session.ctx == nil || session.ctx.Err() != nil { + return + } + if session.gate.Locked() || session.gate.Suspended() { + return + } + if session.keys == nil || session.foreground == nil { + session.status_locked("OCR source unavailable") + return + } + foreground := session.foreground.Foreground() + if !foreground.Client.Known || foreground.Client.Window == 0 { + session.status_locked("OCR source unavailable") + return + } + // An inaccessible client may leave the previous application's AT-SPI focus + // behind. It must not supply object geometry or invalidate this client's job. + if session.focusForeground != foreground.Client.Window { + session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{} + } + if kind == ocr.ObjectSource && (session.geometry == nil || session.interest == nil || !session.focus.Valid()) { + session.status_locked("OCR source unavailable") + return + } + session.generation++ + ctx, cancel := context.WithCancel(session.ctx) + session.cancel = cancel + settings := session.settings().OCR + session.job = ocrJob{ctx: ctx, generation: session.generation, window: session.window, focus: session.focus, foreground: foreground, kind: kind, + autoReadAll: settings.AutoReadAll, copyFirstResult: settings.CopyFirstResult, timestamp: timestamp, + options: ocr.Options{Language: settings.Language, Layout: ocr.Layout(settings.Layout), Scale: settings.Scale, Grayscale: settings.Grayscale, Invert: settings.Invert, BlackWhite: settings.BlackWhite, Threshold: settings.Threshold}} + session.signal() +} + +func (session *ocrSession) refresh_locked(automatic bool) { + if session.cancel == nil || session.cursor == nil || session.refreshing || !session.current_locked(session.job) { + return + } + if automatic && (session.job.kind == ocr.ClipboardSource || !session.settings().OCR.AutoRefresh) { + return + } + // Keep a periodic capture from truncating paced reading. Completion and + // interruption are observed without waiting on the speech worker. + if automatic && session.readAllContext != nil && session.readAllContext.Err() == nil { + select { + case <-session.readAllDone: + default: + session.schedule_refresh_locked() + return + } + } + session.readAllContext, session.readAllDone = nil, nil + if session.autoTimer != nil { + session.autoTimer.Stop() + session.autoTimer = nil + } + session.cancel() + session.generation++ + ctx, cancel := context.WithCancel(session.ctx) + session.cancel = cancel + old := session.job + settings := session.settings().OCR + session.job = ocrJob{ctx: ctx, generation: session.generation, window: old.window, focus: old.focus, + foreground: old.foreground, kind: old.kind, source: session.source, snapshot: session.pixels, + refresh: true, automatic: automatic, + options: ocr.Options{Language: settings.Language, Layout: ocr.Layout(settings.Layout), Scale: settings.Scale, Grayscale: settings.Grayscale, Invert: settings.Invert, BlackWhite: settings.BlackWhite, Threshold: settings.Threshold}} + session.refreshing = true + session.refreshFailed = true + session.signal() +} + +func (session *ocrSession) schedule_refresh_locked() { + if session.autoTimer != nil { + session.autoTimer.Stop() + session.autoTimer = nil + } + settings := session.settings().OCR + if settings.AutoRefresh && session.cursor != nil && session.job.kind != ocr.ClipboardSource && !session.refreshFailed { + session.autoTimer = time.NewTimer(time.Duration(settings.RefreshInterval) * time.Millisecond) + } +} + +func (session *ocrSession) timer_channel() <-chan time.Time { + session.mu.Lock() + defer session.mu.Unlock() + if session.autoTimer != nil { + return session.autoTimer.C + } + return nil +} + +func (session *ocrSession) run() { + defer close(session.done) + for { + select { + case <-session.ctx.Done(): + session.Retire() + session.release_input() + return + case <-session.wake: + session.mu.Lock() + job, active, ready := session.job, session.cancel != nil, session.cursor != nil && !session.refreshing + session.mu.Unlock() + if !active { + session.release_input() + continue + } + if !ready { + session.recognize(job) + } + case request := <-session.copyRequests: + session.copy(request) + case request := <-session.activateRequests: + session.activate(request) + case <-session.timer_channel(): + session.mu.Lock() + session.autoTimer = nil + session.refresh_locked(true) + session.mu.Unlock() + } + } +} + +func (session *ocrSession) release_input() { + if session.keys == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := session.keys.SetOCRReview(ctx, false); err != nil && session.ctx.Err() == nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + // A physical key may still be held. Retry after the chord releases; + // waiting for that release is not a failed native grab transaction. + session.signal() + return + } + // Failed release must stop the reader rather than leave swallowed keys. + select { + case session.failures <- err: + default: + } + } +} + +func (session *ocrSession) current_locked(job ocrJob) bool { + return session.cancel != nil && session.generation == job.generation && job.ctx.Err() == nil && + !session.gate.Locked() && !session.gate.Suspended() && session.focus == job.focus && + session.foreground.Foreground() == job.foreground +} + +func (session *ocrSession) recognize(job ocrJob) { + var err error + if !job.refresh { + err = session.keys.SetOCRContext(job.ctx, job.foreground.Client.Window, true) + } + interest := job.source + var capture ocr.Capture + var result ocr.Result + // Only object capture needs AT-SPI geometry. Window, desktop and clipboard + // jobs belong to the X11 foreground even when it has no accessible objects. + request := ocr.CaptureRequest{Kind: job.kind, Foreground: job.foreground.Client.Window, Generation: job.generation} + if err == nil && job.kind == ocr.ObjectSource { + if !job.refresh { + interest, err = session.interest.ObjectOfInterest(job.ctx) + } + if err == nil { + var zone *atspi.Rectangle + if interest.HasZone { + zone = &interest.Zone + } + var geometry atspi.OCRGeometry + geometry, err = session.geometry.CaptureOCRGeometry(job.ctx, interest.Object, zone) + if err == nil && geometry.Foreground != job.foreground { + err = ocr.ErrSourceChanged + } + request.Bounds = geometry.ScreenBounds + if err == nil { + session.mu.Lock() + if session.current_locked(job) { + session.source = interest + } else { + err = context.Canceled + } + session.mu.Unlock() + } + } + } + if err == nil { + if job.refresh && job.kind == ocr.ClipboardSource { + capture = job.snapshot + } else { + capture, err = session.capture.Capture(job.ctx, request) + } + } + if err == nil { + result, err = session.engine.Recognize(job.ctx, capture.Pixels, job.options) + if err == nil && result.Width > 0 && result.Height > 0 { + capture.Transform.ImageContent = image.Rect(0, 0, result.Width, result.Height) + } + } + if err == nil { + var fresh atspi.OCRGeometry + if err == nil && job.kind == ocr.ObjectSource { + var zone *atspi.Rectangle + if interest.HasZone { + zone = &interest.Zone + } + fresh, err = session.geometry.CaptureOCRGeometry(job.ctx, interest.Object, zone) + if err == nil && (fresh.Foreground != job.foreground || fresh.ScreenBounds != request.Bounds) { + err = ocr.ErrSourceChanged + } + } + if err == nil && result.Text != "" && !job.refresh { + err = session.keys.SetOCRContext(job.ctx, job.foreground.Client.Window, false) + } + } + session.mu.Lock() + current := session.current_locked(job) + if current { + if job.refresh { + session.finish_refresh_locked(job, interest, capture, result, err) + } else if err != nil || result.Text == "" { + session.retire_locked() + message := "OCR recognition failed" + switch { + case errors.Is(err, ocr.ErrUnavailable): + message = "OCR not available" + case err == nil: + message = "No text recognized" + case errors.Is(err, ocr.ErrSourceChanged): + message = "OCR source changed" + case errors.Is(err, atspi.ErrOCRGeometry), errors.Is(err, atspi.ErrObjectNotFound), errors.Is(err, review.ErrNoCurrent), errors.Is(err, ocr.ErrSource), errors.Is(err, ocr.ErrCapture): + message = "OCR source unavailable" + } + session.status_locked(message) + } else { + session.source, session.pixels = interest, capture + session.cursor = ocr.NewCursor(result) + session.selectionAnchor = -1 + if job.autoReadAll && session.readAll != nil { + if session.display != nil { + session.display.EnqueueFrame(session.frame_locked(session.cursor.Current(ocr.LineUnit))) + } + session.read_all_locked(job.timestamp) + } else { + session.present_locked(session.cursor.Current(ocr.LineUnit), ocr.LineUnit) + } + session.schedule_refresh_locked() + if job.copyFirstResult && session.copier != nil && session.copyRequests != nil { + select { + case session.copyRequests <- ocrCopyRequest{ctx: job.ctx, generation: job.generation, text: result.Text, automatic: true}: + default: + } + } + } + } else if session.generation == job.generation { + session.retire_locked() + } + session.mu.Unlock() + if !job.refresh && (!current || err != nil || result.Text == "") { + session.release_input() + } +} + +func (session *ocrSession) status_locked(message string) { + if session.gate.Locked() || session.gate.Suspended() { + return + } + session.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "ocr-status"}) + if session.display != nil { + session.display.EnqueueFrame(braille.Frame{Text: message, Cursor: -1}) + } + session.logger.Debug("OCR status", "text", message) +} + +func (session *ocrSession) present_locked(position ocr.Position, unit ocr.Unit) { + session.present_spelling_locked(position, unit, review.FlatSpeak) +} + +func (session *ocrSession) present_spelling_locked(position ocr.Position, unit ocr.Unit, style review.FlatSpeech) { + text := presentationOCRText(position.Text, unit) + request := speechRequest{Text: text, Role: "text", Cause: "ocr-cursor", context: session.job.ctx, Character: unit == ocr.Character || unit == ocr.LineStart || unit == ocr.LineEnd} + set_review_spelling(&request, position.Text, style) + session.present_request_locked(position, unit, request) +} + +func (session *ocrSession) present_request_locked(position ocr.Position, unit ocr.Unit, request speechRequest) { + session.speech.Enqueue(request) + frame := session.frame_locked(position) + if session.display != nil { + session.display.EnqueueFrame(frame) + } + from, to := session.selection_locked() + session.logger.Debug("OCR cursor presentation", "generation", session.generation, "source_path", session.source.Object.Path, + "focus_path", session.focus.Path, "unit", unit, "offset", position.Offset, "line_start", position.LineStart, + "selection_start", from, "selection_end", to, + "speech_text", request.Text, "spelling", request.Spelling, "braille_text", frame.Text, "braille_cursor", frame.Cursor) +} + +func (session *ocrSession) selection_locked() (int, int) { + if session.cursor == nil || session.selectionAnchor < 0 { + return -1, -1 + } + return min(session.selectionAnchor, session.cursor.Offset()), max(session.selectionAnchor, session.cursor.Offset()) +} + +func (session *ocrSession) frame_locked(position ocr.Position) braille.Frame { + frame := ocr_braille_frame(position) + start := position.LineStart + position.Cursor - frame.Cursor + length := utf8.RuneCountInString(frame.Text) + frame.Virtual = &braille.VirtualRoute{Generation: session.generation, Start: 0, End: length, SourceStart: start} + if from, to := session.selection_locked(); from >= 0 && from != to { + indicator := byte(session.settings().Braille.SelectionIndicator) + if indicator != 0 { + frame.Dots = make([]byte, length) + for offset := max(from, start); offset < min(to, start+length); offset++ { + frame.Dots[offset-start] = indicator + } + } + } + return frame +} + +// Keep long Unicode lines inside the helper frame limit without losing the +// cursor. This is a presentation window; the document keeps the full line. +func ocr_braille_frame(position ocr.Position) braille.Frame { + if len(position.Line) <= 10000 { + return braille.Frame{Text: position.Line, Cursor: position.Cursor} + } + const windowRunes = 2000 // At most 8000 UTF-8 bytes, below the helper limit. + length := utf8.RuneCountInString(position.Line) + start := max(0, min(position.Cursor-windowRunes/2, length-windowRunes)) + end := min(length, start+windowRunes) + startByte, endByte, offset := 0, len(position.Line), 0 + for index := range position.Line { + if offset == start { + startByte = index + } + if offset == end { + endByte = index + break + } + offset++ + } + return braille.Frame{Text: position.Line[startByte:endByte], Cursor: position.Cursor - start} +} + +func presentationOCRText(text string, unit ocr.Unit) string { + if text == "" { + return "blank" + } + if unit == ocr.Character || unit == ocr.LineStart || unit == ocr.LineEnd { + switch text { + case " ": + return "space" + case "\n": + return "new line" + case "\t": + return "tab" + } + } + return text +} + +// Observe runs before native event presentation. Focus/owner changes retire +// synchronously, including changes within one X11 client. OCR never invents an +// AT-SPI object; duplicate source updates cannot overwrite its virtual cursor. +func (session *ocrSession) Observe(event atspi.Event) bool { + if session == nil { + return false + } + session.mu.Lock() + defer session.mu.Unlock() + if session.activating { + if (isFocusGainEvent(event) && event.Source.ID().BusName == session.job.focus.BusName && event.Source.ID() != session.job.focus) || + (event.Type == atspi.DefunctStateEvent && (event.Source.ID() == session.job.focus || event.Source.ID() == session.source.Object)) { + session.activationInterrupted = true + } + return false + } + if session.cancel != nil && session.foreground.Foreground() != session.job.foreground { + session.retire_locked() + } + if isFocusGainEvent(event) && !event.ForegroundMismatch && !eventBypassesXTermHandoff(event) && + (session.foreground == nil || event.Foreground.Epoch == 0 || event.Foreground.Epoch == session.foreground.Foreground().Epoch) { + if event.Type == atspi.WindowActivatedEvent && session.window.Valid() && eventWindow(event) == session.window { + return session.cursor != nil + } + session.window = eventWindow(event) + if session.focus != event.Source.ID() { + session.retire_locked() + } + session.focus = event.Source.ID() + if session.foreground != nil { + session.focusForeground = session.foreground.Foreground().Client.Window + } + } + if event.Type == atspi.WindowDeactivatedEvent || event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent { + if session.foreground != nil && transientForegroundDeactivation(event, session.foreground.Foreground()) { + return false + } + sourceLost := event.Source.ID() == session.focus || session.cancel != nil && + (event.Source.ID() == session.source.Object || event.Source.ID() == session.job.window) + windowLost := event.Type != atspi.DefunctStateEvent && session.cancel != nil && eventWindow(event) == session.job.window + if sourceLost || windowLost { + session.retire_locked() + session.focus = atspi.ObjectID{} + } + } + return session.cursor != nil && !eventBypassesXTermHandoff(event) && + (isFocusGainEvent(event) || event.Type == atspi.CaretEvent || isTextChangeEvent(event.Type) || event.Type == atspi.AccessibleNameEvent || event.Type == atspi.TextSelectionChangedEvent) +} + +func (session *ocrSession) ForegroundChanged() { + if session == nil { + return + } + session.mu.Lock() + defer session.mu.Unlock() + if session.activating { + return + } + if session.cancel != nil && session.foreground.Foreground() != session.job.foreground { + session.retire_locked() + } +} + +func (session *ocrSession) settings() config.Settings { + if session.live != nil { + return session.live.Load() + } + return config.DefaultSettings() +} + +func (session *ocrSession) Reviewing() bool { + if session == nil { + return false + } + session.mu.Lock() + defer session.mu.Unlock() + return session.cancel != nil && session.cursor != nil +} + +// RouteBraille accepts only a current OCR frame's generation and moves the +// virtual cursor. It never forwards a route to AT-SPI or application focus. +func (session *ocrSession) RouteBraille(ctx context.Context, generation uint64, offset int) { + if session == nil || ctx.Err() != nil { + return + } + session.mu.Lock() + defer session.mu.Unlock() + if generation != session.generation || session.cursor == nil || !session.current_locked(session.job) || ctx.Err() != nil { + return + } + position, ok := session.cursor.Set(offset) + if !ok { + return + } + session.selectionAnchor = -1 + session.logger.Debug("OCR braille route", "command", "braille-route", "generation", generation, + "focus_path", session.focus.Path, "source_path", session.source.Object.Path, "offset", offset) + session.present_locked(position, ocr.Character) +} diff --git a/internal/app/ocr_session_test.go b/internal/app/ocr_session_test.go new file mode 100644 index 0000000..57db4e0 --- /dev/null +++ b/internal/app/ocr_session_test.go @@ -0,0 +1,719 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "fmt" + "image" + "log/slog" + "reflect" + "strings" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/ocr" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" +) + +type ocrTestKeys struct { + calls chan string + enter func(context.Context) error + releaseErr error + release func() error +} + +func (keys *ocrTestKeys) SetOCRReview(ctx context.Context, enabled bool) error { + if enabled { + panic("entry must supply foreground") + } + keys.calls <- "released" + if keys.release != nil { + return keys.release() + } + return keys.releaseErr +} +func (keys *ocrTestKeys) SetOCRContext(ctx context.Context, foreground uint64, pending bool) error { + if foreground != 42 { + return ocr.ErrSourceChanged + } + if pending { + keys.calls <- "pending" + return ctx.Err() + } + keys.calls <- "review" + if keys.enter != nil { + return keys.enter(ctx) + } + return ctx.Err() +} + +type ocrTestForeground struct { + mu sync.Mutex + state atspi.ForegroundState +} + +func (monitor *ocrTestForeground) Foreground() atspi.ForegroundState { + monitor.mu.Lock() + defer monitor.mu.Unlock() + return monitor.state +} +func (*ocrTestForeground) ForegroundChanges() <-chan struct{} { return nil } + +type ocrTestGeometry struct { + monitor *ocrTestForeground + calls chan atspi.ObjectID + err error +} + +func (source *ocrTestGeometry) CaptureOCRGeometry(ctx context.Context, id atspi.ObjectID, zone *atspi.Rectangle) (atspi.OCRGeometry, error) { + source.calls <- id + if ctx.Err() != nil { + return atspi.OCRGeometry{}, ctx.Err() + } + return atspi.OCRGeometry{Object: id, Foreground: source.monitor.Foreground(), ScreenBounds: image.Rect(10, 20, 110, 70)}, source.err +} + +type ocrTestInterest struct{ interest review.Interest } + +func (source ocrTestInterest) ObjectOfInterest(context.Context) (review.Interest, error) { + return source.interest, nil +} + +type ocrTestCapture struct { + calls chan ocr.CaptureRequest + run func(context.Context) error + produce func(context.Context, ocr.CaptureRequest) (ocr.Capture, error) +} + +func (source *ocrTestCapture) Capture(ctx context.Context, request ocr.CaptureRequest) (ocr.Capture, error) { + source.calls <- request + if source.produce != nil { + return source.produce(ctx, request) + } + if source.run != nil { + if err := source.run(ctx); err != nil { + return ocr.Capture{}, err + } + } + return ocr.Capture{CaptureHeader: ocr.CaptureHeader{Request: request}, Pixels: image.NewRGBA(image.Rect(0, 0, 100, 50))}, ctx.Err() +} + +type ocrTestEngine struct { + calls chan context.Context + results chan ocr.Result + err error +} + +func (engine *ocrTestEngine) Recognize(ctx context.Context, _ image.Image, _ ocr.Options) (ocr.Result, error) { + engine.calls <- ctx + // Deliberately finish after cancellation to verify the publication guard. + return <-engine.results, engine.err +} + +type ocrTestOutput struct { + speech chan speechRequest + frames chan braille.Frame +} + +type ocrTestCopier struct{ copied chan string } + +func (copier *ocrTestCopier) Copy(ctx context.Context, value string) error { + copier.copied <- value + return ctx.Err() +} + +type ocrTestReadAll struct{ timestamp uint32 } + +func (reader *ocrTestReadAll) BeginReadAll(ctx context.Context, timestamp uint32) context.Context { + reader.timestamp = timestamp + return ctx +} + +func (output *ocrTestOutput) Enqueue(request speechRequest) { output.speech <- request } +func (*ocrTestOutput) Interrupt() {} +func (output *ocrTestOutput) EnqueueFrame(frame braille.Frame) { output.frames <- frame } + +func ocr_test_receive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + select { + case result := <-channel: + return result + case <-time.After(3 * time.Second): + t.Fatal("OCR worker did not respond") + } + var empty T + return empty +} +func ocr_test_focus(path, role string) atspi.Event { + return atspi.Event{Type: atspi.FocusEvent, Source: atspi.Snapshot{BusName: ":1.42", Path: path, Role: role}, Window: atspi.NewObjectID(":1.42", "/window")} +} +func ocr_test_session(t *testing.T) (*ocrSession, *ocrTestKeys, *ocrTestCapture, *ocrTestEngine, *ocrTestOutput) { + t.Helper() + keys := &ocrTestKeys{calls: make(chan string, 32)} + monitor := &ocrTestForeground{state: atspi.ForegroundState{Epoch: 1, Client: xorg.Foreground{Known: true, Window: 42, PID: 123, Width: 100, Height: 50}}} + capture := &ocrTestCapture{calls: make(chan ocr.CaptureRequest, 16)} + engine := &ocrTestEngine{calls: make(chan context.Context, 16), results: make(chan ocr.Result, 16)} + output := &ocrTestOutput{speech: make(chan speechRequest, 32), frames: make(chan braille.Frame, 32)} + session := &ocrSession{logger: slog.New(slog.DiscardHandler), keys: keys, geometry: &ocrTestGeometry{monitor: monitor, calls: make(chan atspi.ObjectID, 32)}, + interest: ocrTestInterest{review.Interest{Object: atspi.NewObjectID(":1.42", "/image")}}, foreground: monitor, capture: capture, engine: engine, + speech: output, display: output, gate: newFocusGate(nil), wake: make(chan struct{}, 1), failures: make(chan error, 1), + copyRequests: make(chan ocrCopyRequest, 1), activateRequests: make(chan ocrActivateRequest, 1), held: make(map[string]bool), selectionAnchor: -1} + if err := session.Start(context.Background()); err != nil { + t.Fatal(err) + } + session.Observe(ocr_test_focus("/focus", "image")) + t.Cleanup(func() { + engine.results <- ocr.Result{} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := session.Stop(ctx); err != nil { + t.Error(err) + } + }) + return session, keys, capture, engine, output +} + +func TestOCRSessionCommandsOwnSourceAndRunePresentation(t *testing.T) { + session, keys, capture, engine, output := ocr_test_session(t) + if !session.Handle(input.CommandEvent{Command: input.OCRObject, Timestamp: 100}) { + t.Fatal("OCR command not claimed") + } + if got := ocr_test_receive(t, keys.calls); got != "pending" { + t.Fatal(got) + } + request := ocr_test_receive(t, capture.calls) + if request.Kind != ocr.ObjectSource || request.Foreground != 42 || request.Bounds != image.Rect(10, 20, 110, 70) || request.Generation == 0 { + t.Fatalf("capture: %+v", request) + } + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "é one\ntwo", Words: []ocr.Word{{Text: "é", Span: ocr.Span{Start: 0, End: 1}}, {Text: "one", Span: ocr.Span{Start: 2, End: 5}}, {Text: "two", Span: ocr.Span{Start: 6, End: 9}}}} + if got := ocr_test_receive(t, keys.calls); got != "review" { + t.Fatal(got) + } + if got := ocr_test_receive(t, output.speech); got.Text != "é one" { + t.Fatal(got) + } + if got := ocr_test_receive(t, output.frames); got.Text != "é one" || got.Cursor != 0 || len(got.Routes) != 0 { + t.Fatalf("braille: %+v", got) + } + session.Input(input.NewKeyEvent(1, true, "Right")) + session.Handle(input.CommandEvent{Command: input.OCRNextCharacter, Timestamp: 101}) + if got := ocr_test_receive(t, output.speech); got.Text != "space" || !got.Character { + t.Fatal(got) + } + if got := ocr_test_receive(t, output.frames); got.Cursor != 1 { + t.Fatal(got) + } + session.Handle(input.CommandEvent{Command: input.OCRNextWord, Timestamp: 102}) + if got := ocr_test_receive(t, output.speech); got.Text != "one" { + t.Fatal(got) + } + if got := ocr_test_receive(t, output.frames); got.Cursor != 2 { + t.Fatal(got) + } + // A repeated activation of the same top-level is not a focus change. + for _, tc := range []struct { + command input.Command + parts []string + }{ + {input.ReviewSpellWord, []string{"o", "n", "e"}}, + {input.ReviewPhoneticWord, []string{"oscar", "november", "echo"}}, + {input.ReviewPhoneticCharacter, []string{"oscar"}}, + } { + if !session.Handle(input.CommandEvent{Command: tc.command}) { + t.Fatal("OCR did not claim spelling") + } + if got := ocr_test_receive(t, output.speech); !reflect.DeepEqual(got.Spelling, tc.parts) { + t.Fatalf("OCR spelling: %+v", got) + } + if got := ocr_test_receive(t, output.frames); got.Text != "é one" || got.Cursor != 2 { + t.Fatalf("spelling moved OCR braille cursor: %+v", got) + } + } + event := ocr_test_focus("/window", "frame") + event.Type = atspi.WindowActivatedEvent + if !session.Observe(event) { + t.Fatal("duplicate activation discarded OCR") + } + event.Type = atspi.WindowDeactivatedEvent + session.Observe(event) + session.Handle(input.CommandEvent{Command: input.OCRNextLine, Timestamp: 103}) + if got := ocr_test_receive(t, output.speech); got.Text != "two" { + t.Fatal(got) + } + if got := ocr_test_receive(t, output.frames); got.Text != "two" || got.Cursor != 2 { + t.Fatal(got) + } + session.Handle(input.CommandEvent{Command: input.OCRDismiss, Timestamp: 104}) + if got := ocr_test_receive(t, output.speech); got.Text != "OCR dismissed" { + t.Fatal(got) + } + if got := ocr_test_receive(t, keys.calls); got != "released" { + t.Fatal(got) + } +} + +func TestOCRSessionSupersedesAndJoinsBeforeNewJob(t *testing.T) { + session, keys, capture, engine, output := ocr_test_session(t) + session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) + first := ocr_test_receive(t, capture.calls) + firstContext := ocr_test_receive(t, engine.calls) + session.Handle(input.CommandEvent{Command: input.OCRClipboard, Timestamp: 101}) + if firstContext.Err() != context.Canceled { + t.Fatal("replacement did not cancel old job") + } + select { + case <-capture.calls: + t.Fatal("new capture overlapped old recognition") + default: + } + engine.results <- ocr.Result{Text: "stale result"} + second := ocr_test_receive(t, capture.calls) + if first.Kind != ocr.WindowSource || second.Kind != ocr.ClipboardSource || second.Generation <= first.Generation { + t.Fatalf("generations: %+v %+v", first, second) + } + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "current result"} + if got := ocr_test_receive(t, output.speech); got.Text != "current result" { + t.Fatal(got) + } + // Source options/pixels are owned until dismissal, even for clipboard OCR. + session.mu.Lock() + kind := session.pixels.Request.Kind + session.mu.Unlock() + if kind != ocr.ClipboardSource { + t.Fatal(kind) + } + _ = keys +} + +func TestOCRSessionRetiresEveryJobBoundary(t *testing.T) { + for _, boundary := range []string{"input", "same-client-focus", "password", "source-destroyed", "foreground", "lock", "terminal", "dialog", "shutdown"} { + t.Run(boundary, func(t *testing.T) { + session, keys, _, engine, output := ocr_test_session(t) + session.Handle(input.CommandEvent{Command: input.OCRObject, Timestamp: 100}) + jobContext := ocr_test_receive(t, engine.calls) + switch boundary { + case "input": + event := input.NewKeyEvent(1, true, "a") + event.Timestamp = 101 + session.Input(event) + case "same-client-focus": + session.Observe(ocr_test_focus("/other", "image")) + case "password": + session.Observe(ocr_test_focus("/password", "password text")) + case "source-destroyed": + event := ocr_test_focus("/image", "image") + event.Type = atspi.DefunctStateEvent + session.Observe(event) + case "foreground": + monitor := session.foreground.(*ocrTestForeground) + monitor.mu.Lock() + monitor.state.Epoch++ + monitor.mu.Unlock() + session.ForegroundChanged() + case "lock": + session.gate.ocr = session + session.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) + case "terminal": + session.gate.ocr = session + session.gate.SetSuspended(true) + case "dialog": + if session.Handle(input.CommandEvent{Command: input.Preferences}) { + t.Fatal("preferences swallowed") + } + case "shutdown": + session.stop() + } + select { + case <-jobContext.Done(): + case <-time.After(3 * time.Second): + t.Fatal("job not canceled") + } + engine.results <- ocr.Result{Text: "late private text"} + for ocr_test_receive(t, keys.calls) != "released" { + } + select { + case got := <-output.speech: + t.Fatalf("late output: %+v", got) + default: + } + }) + } +} + +func TestOCRSessionPasswordFocusAllowsVisibleCapture(t *testing.T) { + for _, command := range []input.Command{input.OCRObject, input.OCRWindow, input.OCRDesktop, input.OCRClipboard} { + t.Run(string(command), func(t *testing.T) { + session, _, capture, engine, output := ocr_test_session(t) + session.Observe(ocr_test_focus("/protected", "password text")) + session.Handle(input.CommandEvent{Command: command}) + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "Visible label"} + if got := ocr_test_receive(t, output.speech); got.Text != "Visible label" { + t.Fatal(got) + } + }) + } +} + +func TestOCRSessionScreenSourcesWithoutAccessibility(t *testing.T) { + for _, command := range []input.Command{input.OCRWindow, input.OCRDesktop, input.OCRClipboard} { + for _, staleFocus := range []bool{false, true} { + t.Run(string(command)+fmt.Sprint("/stale=", staleFocus), func(t *testing.T) { + session, _, capture, engine, output := ocr_test_session(t) + session.geometry, session.interest = nil, nil + if staleFocus { + session.focusForeground = 99 + } else { + session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{} + } + session.Handle(input.CommandEvent{Command: command, Timestamp: 100}) + request := ocr_test_receive(t, capture.calls) + if request.Foreground != 42 || request.Bounds != (image.Rectangle{}) { + t.Fatalf("non-object capture: %+v", request) + } + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "Visible text"} + if got := ocr_test_receive(t, output.speech); got.Text != "Visible text" { + t.Fatal(got) + } + ocr_test_receive(t, output.frames) + session.Handle(input.CommandEvent{Command: input.OCRRefresh, Timestamp: 101}) + if command != input.OCRClipboard { + ocr_test_receive(t, capture.calls) + } + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "Updated text"} + if got := ocr_test_receive(t, output.speech); got.Text != "Updated text" { + t.Fatal(got) + } + ocr_test_receive(t, output.frames) + session.Handle(input.CommandEvent{Command: input.OCRDismiss}) + if got := ocr_test_receive(t, output.speech); got.Text != "OCR dismissed" { + t.Fatal(got) + } + }) + } + } +} + +func TestOCRSessionObjectStillRequiresAccessibleFocus(t *testing.T) { + for _, staleFocus := range []bool{false, true} { + session, _, capture, _, output := ocr_test_session(t) + if staleFocus { + session.focusForeground = 99 + } else { + session.focus = atspi.ObjectID{} + } + session.Handle(input.CommandEvent{Command: input.OCRObject}) + if got := ocr_test_receive(t, output.speech); got.Text != "OCR source unavailable" { + t.Fatal(got) + } + select { + case <-capture.calls: + t.Fatal("object OCR captured without a current accessible focus") + default: + } + } +} + +func TestOCRSessionLockRetiresInaccessibleWindowResult(t *testing.T) { + session, keys, _, engine, output := ocr_test_session(t) + session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{} + session.geometry = nil + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + ocr_test_receive(t, keys.calls) + jobContext := ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "Visible text"} + ocr_test_receive(t, keys.calls) + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + session.gate.ocr = session + session.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) + if jobContext.Err() != context.Canceled || session.Reviewing() { + t.Fatal("screen lock retained OCR result") + } + if got := ocr_test_receive(t, output.frames); got.Text != "" { + t.Fatal("screen lock retained OCR braille text") + } + for ocr_test_receive(t, keys.calls) != "released" { + } + for _, command := range []input.Command{input.OCRObject, input.OCRWindow, input.OCRDesktop, input.OCRClipboard} { + session.Handle(input.CommandEvent{Command: command}) + } + select { + case <-engine.calls: + t.Fatal("recognition started while locked") + default: + } +} + +func TestOCRSessionSourceAndDependencyErrors(t *testing.T) { + for _, failure := range []error{ocr.ErrUnavailable, nil, atspi.ErrOCRGeometry} { + session, keys, capture, engine, output := ocr_test_session(t) + engine.err = failure + if errors.Is(failure, atspi.ErrOCRGeometry) { + session.geometry.(*ocrTestGeometry).err = failure + } + command := input.OCRWindow + if errors.Is(failure, atspi.ErrOCRGeometry) { + command = input.OCRObject + } + session.Handle(input.CommandEvent{Command: command}) + if !errors.Is(failure, atspi.ErrOCRGeometry) { + ocr_test_receive(t, capture.calls) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{} + } + want := "No text recognized" + if failure == ocr.ErrUnavailable { + want = "OCR not available" + } + if failure == atspi.ErrOCRGeometry { + want = "OCR source unavailable" + } + if got := ocr_test_receive(t, output.speech); got.Text != want { + t.Fatal(got) + } + for ocr_test_receive(t, keys.calls) != "released" { + } + } +} + +func TestOCRSessionDelayedInitiatingInputAndChordRetirement(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) + jobContext := ocr_test_receive(t, engine.calls) + event := input.NewKeyEvent(1, true, "o") + event.Timestamp = 100 + session.Input(event) + if jobContext.Err() != nil { + t.Fatal("delayed command observation canceled job") + } + engine.results <- ocr.Result{Text: "result"} + ocr_test_receive(t, output.speech) + session.Input(input.NewKeyEvent(2, true, "Control_L")) + session.Input(input.NewKeyEvent(3, true, "Right")) + if jobContext.Err() != nil { + t.Fatal("claimed Ctrl+Right canceled result") + } + session.Input(input.NewKeyEvent(3, false, "Right")) + session.Input(input.NewKeyEvent(4, true, "g")) + if jobContext.Err() != context.Canceled { + t.Fatal("unbound Ctrl+G did not retire") + } +} + +func TestOCRSessionCancellationDuringCaptureAndInputEntry(t *testing.T) { + for _, phase := range []string{"capture", "input-entry"} { + t.Run(phase, func(t *testing.T) { + session, keys, capture, engine, output := ocr_test_session(t) + started := make(chan context.Context, 1) + block := func(ctx context.Context) error { started <- ctx; <-ctx.Done(); return ctx.Err() } + if phase == "capture" { + capture.run = block + } else { + keys.enter = block + } + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + if phase == "input-entry" { + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "ready"} + } + jobContext := ocr_test_receive(t, started) + session.Retire() + if jobContext.Err() != context.Canceled { + t.Fatal("native operation not canceled") + } + for ocr_test_receive(t, keys.calls) != "released" { + } + select { + case got := <-output.speech: + t.Fatalf("canceled operation published: %+v", got) + default: + } + }) + } +} + +func TestOCRSessionReleaseFailureIsReported(t *testing.T) { + session, keys, _, engine, output := ocr_test_session(t) + keys.releaseErr = errors.New("release failed") + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "ready"} + ocr_test_receive(t, output.speech) + session.Retire() + if err := ocr_test_receive(t, session.failures); err != keys.releaseErr { + t.Fatal(err) + } +} + +func TestOCRSessionWaitForPhysicalReleaseRetries(t *testing.T) { + session, keys, _, engine, output := ocr_test_session(t) + count := 0 + keys.release = func() error { + count++ + if count == 1 { + return context.DeadlineExceeded + } + return nil + } + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "ready"} + ocr_test_receive(t, output.speech) + session.Retire() + releases := 0 + for releases < 2 { + if ocr_test_receive(t, keys.calls) == "released" { + releases++ + } + } + select { + case err := <-session.failures: + t.Fatalf("held physical key stopped reader: %v", err) + default: + } +} + +func TestOCRSessionRecognizesCthulhuAndNativeKeypadNames(t *testing.T) { + for _, name := range []string{"KP_Insert", "Caps_Lock", "KP_Next", "KP_Prior"} { + t.Run(name, func(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + settings := session.settings() + settings.CthulhuModifierKeys = []string{"Insert", "KP_Insert", "Caps_Lock"} + session.live = &liveSettings{} + session.live.Store(settings) + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + jobContext := ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "ready"} + ocr_test_receive(t, output.speech) + session.Input(input.NewKeyEvent(1, true, name)) + if jobContext.Err() != nil { + t.Fatalf("claimed native key %s retired OCR", name) + } + }) + } +} + +func TestOCRBrailleKeepsCursorInLongUnicodeLine(t *testing.T) { + line := strings.Repeat("界", 5000) + frame := ocr_braille_frame(ocr.Position{Line: line, Cursor: 4999}) + if len(frame.Text) > 10000 || frame.Cursor != 1999 || len([]rune(frame.Text)) != 2000 || []rune(frame.Text)[frame.Cursor] != '界' { + t.Fatalf("long-line cursor lost: bytes=%d cursor=%d", len(frame.Text), frame.Cursor) + } +} + +func TestOCRSelectionCopySayAllAndBrailleRouting(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + copier := &ocrTestCopier{copied: make(chan string, 2)} + reader := &ocrTestReadAll{} + session.copier = copier + session.readAll = reader + session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "é one\n猫 two"} + ocr_test_receive(t, output.speech) + initial := ocr_test_receive(t, output.frames) + if initial.Virtual == nil || initial.Virtual.Generation == 0 || initial.Virtual.SourceStart != 0 { + t.Fatalf("missing OCR route: %+v", initial) + } + session.Handle(input.CommandEvent{Command: input.OCRNextCharacter, Chord: input.CommandChord{Modifiers: 1}, Timestamp: 101}) + ocr_test_receive(t, output.speech) + selected := ocr_test_receive(t, output.frames) + if selected.Cursor != 1 || len(selected.Dots) != 5 || selected.Dots[0] == 0 || selected.Dots[1] != 0 { + t.Fatalf("selection frame: %+v", selected) + } + session.Handle(input.CommandEvent{Command: input.OCRCopySelection, Timestamp: 102}) + if got := ocr_test_receive(t, copier.copied); got != "é" { + t.Fatalf("copied %q", got) + } + if got := ocr_test_receive(t, output.speech); got.Text != "OCR text copied to clipboard" { + t.Fatal(got) + } + ocr_test_receive(t, output.frames) // copy status + session.Handle(input.CommandEvent{Command: input.OCRSelectAll, Timestamp: 103}) + if got := ocr_test_receive(t, output.speech); got.Text != "OCR text selected" { + t.Fatal(got) + } + ocr_test_receive(t, output.frames) + session.Handle(input.CommandEvent{Command: input.OCRCopySelection, Timestamp: 104}) + if got := ocr_test_receive(t, copier.copied); got != "é one\n猫 two" { + t.Fatalf("copied all %q", got) + } + ocr_test_receive(t, output.speech) + ocr_test_receive(t, output.frames) + session.RouteBraille(context.Background(), initial.Virtual.Generation, 6) + if got := ocr_test_receive(t, output.speech); got.Text != "猫" { + t.Fatal(got) + } + routed := ocr_test_receive(t, output.frames) + if routed.Text != "猫 two" || routed.Cursor != 0 || routed.Virtual.SourceStart != 6 { + t.Fatalf("routed frame: %+v", routed) + } + session.Handle(input.CommandEvent{Command: input.ReadAll, Timestamp: 105}) + request := ocr_test_receive(t, output.speech) + if reader.timestamp != 105 || len(request.ReadAll) != 1 || request.ReadAll[0].Text != "猫 two" { + t.Fatalf("Say All request: %+v", request) + } + request.ReadAll[0].Began() + if got := ocr_test_receive(t, output.frames); got.Cursor != 0 || got.Virtual == nil { + t.Fatalf("Say All braille: %+v", got) + } + session.Retire() + request.ReadAll[0].Began() + select { + case got := <-output.frames: + if got.Text != "" { + t.Fatalf("late Say All frame: %+v", got) + } + default: + } +} + +func TestOCRBrailleWorkerRoutesOnlyCurrentVirtualFrame(t *testing.T) { + session, _, _, engine, output := ocr_test_session(t) + session.Handle(input.CommandEvent{Command: input.OCRWindow}) + ocr_test_receive(t, engine.calls) + engine.results <- ocr.Result{Text: "é猫"} + ocr_test_receive(t, output.speech) + frame := ocr_test_receive(t, output.frames) + native := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 1)} + worker := &brailleWorker{ocr: session, router: native, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}} + worker.EnqueueFrame(frame) + id := worker.current().ID + worker.route(context.Background(), braille.RoutingEvent{ID: id + 1, Offset: 1}) + select { + case <-output.speech: + t.Fatal("stale frame moved OCR cursor") + default: + } + worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: 1}) + if got := ocr_test_receive(t, output.speech); got.Text != "猫" { + t.Fatal(got) + } + if len(native.calls) != 0 { + t.Fatal("OCR routing moved the native caret") + } + session.Retire() + ocr_test_receive(t, output.frames) // routed presentation + ocr_test_receive(t, output.frames) // retired blank frame + worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: 0}) + select { + case <-output.speech: + t.Fatal("retired OCR route spoke") + default: + } +} diff --git a/internal/app/output_queue.go b/internal/app/output_queue.go new file mode 100644 index 0000000..9fe60f1 --- /dev/null +++ b/internal/app/output_queue.go @@ -0,0 +1,71 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +type outputQueue struct { + speech speechEnqueuer + sounds soundService + braille brailleFrameEnqueuer + live *liveSettings +} + +func (queue outputQueue) Enqueue(request speechRequest) { + if len(request.Sound) != 0 && (queue.sounds == nil || !queue.sounds.EnqueueBatch(request.Sound)) && request.FallbackText != "" { + request.Text = request.FallbackText + request.Chunks = request.FallbackChunks + } + if request.Text != "" || len(request.ReadAll) != 0 { + queue.speech.Enqueue(request) + } + if queue.braille != nil && queue.live != nil && request.Text != "" && request.Role == "system" && + request.Cause != "read-all" && request.Cause != "document-caret-line" { + if flasher, ok := queue.braille.(interface { + Flash(string, config.BrailleOptions) bool + }); ok { + flasher.Flash(request.Text, queue.live.Load().Braille) + } + } +} +func (queue outputQueue) Interrupt() { queue.speech.Interrupt() } + +func (queue outputQueue) InterruptForUser() { + if speech, ok := queue.speech.(interface{ InterruptForUser() }); ok { + speech.InterruptForUser() + } else { + queue.speech.Interrupt() + } +} + +func (queue outputQueue) InterruptForInput(event input.Event) bool { + if speech, ok := queue.speech.(interface{ InterruptForInput(input.Event) bool }); ok { + return speech.InterruptForInput(event) + } + queue.InterruptForUser() + return true +} + +type discardSpeech struct{} + +func (discardSpeech) Enqueue(speechRequest) {} + +func (discardSpeech) Interrupt() {} diff --git a/internal/app/output_queue_test.go b/internal/app/output_queue_test.go new file mode 100644 index 0000000..6b37946 --- /dev/null +++ b/internal/app/output_queue_test.go @@ -0,0 +1,54 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "reflect" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func TestOutputQueueUsesSpeechFallbackWhenSoundBatchIsRejected(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + queue := outputQueue{speech: speech, sounds: rejectingSound{}} + queue.Enqueue(speechRequest{ + Text: "Power", Chunks: []string{"Power"}, FallbackText: "Power, toggle button, pressed", + FallbackChunks: []string{"Power", "toggle button", "pressed"}, + Sound: []sound.Cue{{Name: "button"}, {Name: "togglebutton_checked"}}, + }) + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.deliver(context.Background(), <-speech.requests); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(speaker.parts, []string{"Power", "toggle button", "pressed"}) { + t.Fatalf("sound fallback lost its pause chunks: %v", speaker.parts) + } + select { + case got := <-speech.spoken: + if got != "Power, toggle button, pressed" { + t.Fatalf("fallback speech = %q", got) + } + case <-time.After(time.Second): + t.Fatal("missing rejected-sound fallback speech") + } +} diff --git a/internal/app/pause_chunks_test.go b/internal/app/pause_chunks_test.go new file mode 100644 index 0000000..94d8c7b --- /dev/null +++ b/internal/app/pause_chunks_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "log/slog" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestPauseChunksInterruptRemainingAnnouncement(t *testing.T) { + speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + presenter := eventPresenter{state: &eventLoopState{}, + logger: slog.New(slog.DiscardHandler), speech: worker} + presenter.state.presentationOptions = presentation.DefaultOptions() + presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Save, draft", Role: "button", Description: "Save document"})) + close(expectSpeechCall(t, speaker, "cancel").done) + first := expectSpeechCall(t, speaker, "Save, draft") + worker.InterruptForUser() + close(expectSpeechCall(t, speaker, "cancel").done) + worker.Enqueue(speechRequest{Text: "New focus"}) + close(first.done) + close(expectSpeechCall(t, speaker, "cancel").done) // Settle the interrupted call. + close(expectSpeechCall(t, speaker, "cancel").done) // Begin the new announcement. + close(expectSpeechCall(t, speaker, "New focus").done) +} + +func TestPauseChunksStopOnBackendFailure(t *testing.T) { + failure := errors.New("speech failed") + speaker := &fakeSpeaker{speakError: failure} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.history = newSpeechHistory(10) + spoken := presentation.FocusSpeechWithOptions(atspi.Snapshot{Name: "Save", Role: "button"}, presentation.DefaultOptions()) + err := worker.deliver(context.Background(), speechRequest{Text: spoken.Text, Chunks: spoken.Chunks}) + if !errors.Is(err, failure) { + t.Fatalf("delivery error = %v", err) + } + if got := worker.history.snapshot(); !reflect.DeepEqual(got, []string{"Save"}) { + t.Fatalf("undelivered chunks entered history: %v", got) + } +} + +func TestPauseChunksPreservePresentationDecorations(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSound = false + settings.Reading.PresentTooltips = true + live := &liveSettings{} + live.Store(settings) + options := presentationSettingsFromConfig(settings, true) + logger := slog.New(slog.DiscardHandler) + for _, test := range []struct { + name string + present func(*fakeSpeechQueue) + want []string + }{ + {"window title", func(queue *fakeSpeechQueue) { + presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live} + event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{Name: "Save", Role: "button"}) + event.WindowTitle = "Save, button" + presenter.speak(event) + }, []string{"Save, button", "Save", "button"}}, + {"table", func(queue *fakeSpeechQueue) { + presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live} + event := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/cell") + event.Source.Name, event.Source.Role, event.Source.GUITable = "One, Two", "table cell", true + event.Source.TableName, event.Source.TablePath = "Example, table", "/table" + event.WindowDescription = "Table help, details" + presenter.speak(event) + }, []string{"Example, table", "One, Two", "Table help, details"}}, + {"prompt", func(queue *fakeSpeechQueue) { + presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live} + event := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/button") + event.Source.Name, event.Source.Role = "Save", "button" + event.WindowTitle, event.WindowMessage = "Question, details", "Save draft, or discard?" + presenter.speak(event) + }, []string{"Question, details", "Save draft, or discard?"}}, + {"browser wrapping", func(queue *fakeSpeechQueue) { + presentDocumentResult(logger, queue, review.Result{Moved: true, DocumentWrapped: true, + DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Name: "Read, draft", Role: "link"}}, + CaretErr: errors.New("caret unavailable")}, options) + }, []string{"Read, draft", "link", "Wrapping to top.", "Could not move the browser caret."}}, + {"tooltip", func(queue *fakeSpeechQueue) { + handler := accessibilityEventHandler{state: &eventLoopState{}, speech: queue, live: live} + event := atspi.NewEvent(atspi.ShowingStateEvent, atspi.Snapshot{Name: "Save, draft", Role: "tool tip"}) + event.Detail1 = 1 + handler.handleTooltip(event) + }, []string{"Save, draft", "tool tip"}}, + {"object review", func(queue *fakeSpeechQueue) { + presentObjectReviewResult(logger, queue, review.Result{Moved: true, + Position: review.Position{Current: atspi.Snapshot{Name: "Read, draft", Role: "link"}}}, options) + }, []string{"Read, draft", "link"}}, + } { + t.Run(test.name, func(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + test.present(queue) + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(logger, speaker) + if len(queue.requests) != 1 { + t.Fatal("presentation did not queue one announcement") + } + if err := worker.deliver(context.Background(), <-queue.requests); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(speaker.parts, test.want) { + t.Fatalf("backend chunks = %q, want %q", speaker.parts, test.want) + } + }) + } +} + +type PauseWaitSpeaker struct { + spellingTestSpeaker + voices []string + progress []int + line int +} + +func (speaker *PauseWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, began func()) error { + if began != nil { + began() + } + speaker.voices = append(speaker.voices, voice) + speaker.progress = append(speaker.progress, speaker.line) + return speaker.Speak(ctx, text) +} + +func (speaker *PauseWaitSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { + return speaker.SpeakVoiceAndWait(ctx, text, "default", began) +} + +func TestPauseChunksSayAllKeepsLineProgressAndVoice(t *testing.T) { + for _, enabled := range []bool{true, false} { + for _, style := range []string{"line", "sentence"} { + speaker := &PauseWaitSpeaker{line: -1} + settings := config.DefaultSettings() + settings.Reading.EnablePauseBreaks = enabled + settings.Reading.EnableIndentation = true + settings.SayAllStyle = style + queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + lines := []atspi.DocumentLine{ + {Contents: []atspi.DocumentContent{{Text: "First, sentence. Second!", RawText: " First, sentence. Second!", + Snapshot: atspi.Snapshot{Path: "/link", Role: "link", Visited: true}}}}, + {Contents: []atspi.DocumentContent{{Text: "Next line", Snapshot: atspi.Snapshot{Path: "/text", Role: "text"}}}}, + } + var callbacks []int + result := review.Result{ReadAllContext: context.Background(), ReadAllLines: lines, + ReadAllProgress: func(line int) { callbacks = append(callbacks, line); speaker.line = line }} + presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentationSettingsFromConfig(settings, true)) + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.reading = func() config.ReadingSettings { return settings.Reading } + if err := worker.deliver(context.Background(), <-queue.requests); err != nil { + t.Fatal(err) + } + want := []string{"indentation 2 columns, First, sentence. Second!", "visited link", "indentation 0 columns, Next line"} + wantProgress := []int{0, 0, 1} + wantVoices := []string{"hyperlink", "hyperlink", "default"} + if !enabled { + want = []string{"indentation 2 columns, First, sentence. Second! visited link", "indentation 0 columns, Next line"} + wantProgress, wantVoices = []int{0, 1}, []string{"hyperlink", "default"} + } + if style == "sentence" { + want[0] = "indentation 2 columns, First, sentence." + second := "Second!" + if !enabled { + want = []string{want[0], second, "visited link", want[1]} + } else { + want = []string{want[0], second, want[1], want[2]} + } + wantProgress, wantVoices = []int{0, 0, 0, 1}, []string{"hyperlink", "hyperlink", "hyperlink", "default"} + } + if !reflect.DeepEqual(speaker.parts, want) || !reflect.DeepEqual(speaker.progress, wantProgress) || + !reflect.DeepEqual(speaker.voices, wantVoices) || !reflect.DeepEqual(callbacks, []int{0, 1}) { + t.Fatalf("enabled=%v style=%s: speech=%v progress=%v voices=%v callbacks=%v", enabled, style, + speaker.parts, speaker.progress, speaker.voices, callbacks) + } + } + } +} diff --git a/internal/app/pending_presentation.go b/internal/app/pending_presentation.go new file mode 100644 index 0000000..de0b291 --- /dev/null +++ b/internal/app/pending_presentation.go @@ -0,0 +1,57 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// pendingPresentation owns one coalesced event and its captured input intent. +// Only the main event loop accesses it; timers never run presentation callbacks. +// Replacing or clearing an event disconnects its old timer from the select loop. +type pendingPresentation struct { + event atspi.Event + intent input.Intent + timer *time.Timer + ready <-chan time.Time +} + +func (pending *pendingPresentation) schedule(event atspi.Event, intent input.Intent, delay time.Duration) { + pending.clear() + pending.event, pending.intent = event, intent + pending.timer = time.NewTimer(delay) + pending.ready = pending.timer.C +} + +func (pending *pendingPresentation) clear() { + if pending.timer != nil { + pending.timer.Stop() + } + *pending = pendingPresentation{} +} + +func (pending *pendingPresentation) take() (atspi.Event, input.Intent) { + event, intent := pending.event, pending.intent + pending.clear() + return event, intent +} + +// pendingPresentations bounds delayed presentation to one event per family. +// Its lifetime is the event loop's lifetime, including adapter closure. +type pendingPresentations struct { + caret pendingPresentation + terminalFocus pendingPresentation + terminalDeletion pendingPresentation +} + +func (pending *pendingPresentations) clear() { + pending.caret.clear() + pending.terminalFocus.clear() + pending.terminalDeletion.clear() +} diff --git a/internal/app/preferences.go b/internal/app/preferences.go new file mode 100644 index 0000000..bd07a7b --- /dev/null +++ b/internal/app/preferences.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "strings" + "sync" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +type speechSettingsUpdater interface { + UpdateSettings(context.Context, speech.Settings) error +} + +type brailleSettingsUpdater interface { + Configure(context.Context, bool) error +} + +// preferencesWorker owns session settings and at most one UI child. Open has a +// capacity-one, coalescing mailbox: repeated commands cannot queue windows or +// block input. The child permits one outstanding apply; messages are capped at +// 512 KiB. Shutdown cancels backend I/O, terminates the child, and joins the worker. +type preferencesWorker struct { + tentacles *tentacleRuntime + logger *slog.Logger + settings config.Settings + globalSettings config.Settings + appSettings map[string]config.Settings + activeApp string + editApp string + editing config.Settings + editingActive bool + choosingApplication bool + updater any + open chan string + applicationChanges chan string + done chan struct{} + cancel context.CancelFunc + once sync.Once + live *liveSettings + history *speechHistory + speechWorker *speechWorker + refreshHistory func() + sound soundSettingsUpdater + geometry soundGeometryCaptureConfigurer + textAttributes textAttributeCaptureConfigurer + bindings interface { + SetKeyBindings(context.Context, []input.BindingOverride) error + } + braille brailleSettingsUpdater + documentReview *review.Worker + socket string + speechCommands chan input.Command + remoteChanges chan remoteSettingsChange + speechSetting int + announce func(string) + announceApply func(string) + catalog speech.Catalog + discover func(context.Context, string) (speech.Catalog, error) +} + +func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker { + return &preferencesWorker{logger: logger, settings: settings, globalSettings: settings, appSettings: make(map[string]config.Settings), updater: updater, + speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})} +} + +func (worker *preferencesWorker) Start(ctx context.Context) error { + ctx, worker.cancel = context.WithCancel(ctx) + go func() { + defer close(worker.done) + for { + select { + case <-ctx.Done(): + return + case command := <-worker.speechCommands: + worker.handleSpeechCommand(ctx, command) + case change := <-worker.remoteChanges: + change.result <- worker.applyRemoteChange(ctx, change) + case application := <-worker.applicationChanges: + worker.switchApplication(ctx, application) + case application := <-worker.open: + worker.choosingApplication = application == "\x01" + if application != "\x01" && !worker.beginEdit(ctx, application) { + continue + } + if err := worker.window(ctx); err != nil && ctx.Err() == nil { + worker.logger.Error("preferences window", "error", err) + } + worker.editingActive = false + worker.choosingApplication = false + select { + case application := <-worker.applicationChanges: + worker.switchApplication(ctx, application) + default: + } + } + } + }() + return nil +} + +func (worker *preferencesWorker) Open() { + select { + case worker.open <- "": + default: + } +} + +func (worker *preferencesWorker) OpenApplication() { + select { + case worker.open <- "\x00": + default: + } +} + +func (worker *preferencesWorker) ChooseApplication() { + select { + case worker.open <- "\x01": + default: + } +} + +// Focus handling only publishes the latest foreground application and never +// waits for settings I/O on the native event loop. +func (worker *preferencesWorker) SetApplication(application string) { + application = strings.TrimSpace(application) + if application == "" || strings.HasPrefix(strings.ToLower(application), "cthulhu") { + return + } + if worker.live != nil { + worker.live.requestedApplication.Store(&application) + worker.live.notify_clipboard() + } + select { + case worker.applicationChanges <- application: + default: + select { + case <-worker.applicationChanges: + default: + } + select { + case worker.applicationChanges <- application: + default: + } + } +} + +func (worker *preferencesWorker) Stop(ctx context.Context) error { + worker.once.Do(func() { + if worker.cancel != nil { + worker.cancel() + } + }) + select { + case <-worker.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/app/preferences_application.go b/internal/app/preferences_application.go new file mode 100644 index 0000000..eeddd58 --- /dev/null +++ b/internal/app/preferences_application.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "strings" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func (worker *preferencesWorker) applicationSettings(application string) (config.Settings, error) { + if settings, ok := worker.appSettings[application]; ok { + return settings, nil + } + store, err := config.NewStore(worker.globalSettings.Path) + if err != nil { + return config.Settings{}, err + } + persistedGlobal, err := store.Load() + if err != nil { + return config.Settings{}, err + } + settings, mapped, err := store.LoadForApplication(application) + if err != nil { + return config.Settings{}, err + } + if !mapped { + return worker.globalSettings, nil + } + settings = config.RebaseApplicationSettings(persistedGlobal, worker.globalSettings, settings) + worker.appSettings[application] = settings + return settings, nil +} + +func (worker *preferencesWorker) switchApplication(ctx context.Context, application string) { + application = strings.TrimSpace(application) + if application == "" || application == worker.activeApp { + return + } + settings, err := worker.applicationSettings(application) + if err == nil { + err = worker.applyRuntime(ctx, settings) + } + if err != nil { + worker.logger.Error("switch application settings", "application", application, "error", err) + return + } + worker.activeApp = application + if worker.live != nil { + worker.live.application.Store(&application) + worker.live.notify_clipboard() + } +} + +func (worker *preferencesWorker) beginEdit(ctx context.Context, application string) bool { + // A focus event and its shortcut can arrive on separate goroutines. Use the + // latest accepted focus before deciding which application to edit. + select { + case focused := <-worker.applicationChanges: + worker.switchApplication(ctx, focused) + default: + } + if application == "\x00" { + application = worker.activeApp + if application == "" { + worker.announceSpeechSetting("No active application for application preferences.") + return false + } + } + worker.editApp = application + worker.editingActive = true + worker.editing = worker.globalSettings + if application != "" { + settings, err := worker.applicationSettings(application) + if err != nil { + worker.logger.Error("load application settings", "application", application, "error", err) + worker.announceSpeechSetting("Could not load application preferences.") + worker.editingActive = false + return false + } + worker.editing = settings + } + return true +} diff --git a/internal/app/preferences_application_test.go b/internal/app/preferences_application_test.go new file mode 100644 index 0000000..4ace5f4 --- /dev/null +++ b/internal/app/preferences_application_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "context" + "log/slog" + "path/filepath" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func TestApplicationPreferencesSwitchAndPersistIndependently(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + global := config.DefaultSettings() + global.Path = path + store, err := config.NewStore(path) + if err != nil { + t.Fatal(err) + } + if err := store.Save(global); err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, + settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) + worker.live = &liveSettings{} + worker.live.Store(global) + ctx := context.Background() + worker.switchApplication(ctx, "Example Editor") + if !worker.beginEdit(ctx, "\x00") || worker.editApp != "Example Editor" { + t.Fatal("application shortcut did not select focused application") + } + values := preferences.FromSettings(worker.editing) + values.TimeFormat = "24-hour" + values.Reading.AutomaticallyReadClipboardChanges = true + response := worker.apply(ctx, preferences.Request{Values: values, Save: true}) + if !response.Accepted || worker.settings.TimeFormat != "24-hour" || !worker.settings.Reading.AutomaticallyReadClipboardChanges { + t.Fatalf("application settings were not applied: %+v", response) + } + worker.editingActive = false + worker.switchApplication(ctx, "Other Editor") + if worker.settings.TimeFormat != global.TimeFormat || worker.settings.Reading.AutomaticallyReadClipboardChanges { + t.Fatal("application settings leaked to another application") + } + worker.switchApplication(ctx, "Example Editor") + if worker.settings.TimeFormat != "24-hour" || worker.live.Load().TimeFormat != "24-hour" { + t.Fatal("application settings were not restored on return") + } + worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) + worker.handleSpeechCommand(ctx, input.SaveSpeechSettings) + if !worker.beginEdit(ctx, "") { + t.Fatal("global preferences did not open") + } + globalValues := preferences.FromSettings(worker.editing) + globalValues.DateFormat = "numbers-mdy" + if response := worker.apply(ctx, preferences.Request{Values: globalValues, Save: true}); !response.Accepted { + t.Fatalf("global setting was not applied: %+v", response) + } + worker.editingActive = false + if worker.settings.TimeFormat != "24-hour" || worker.settings.DateFormat != "numbers-mdy" { + t.Fatalf("application session did not inherit global date: %+v", worker.settings) + } + loaded, err := store.Load() + if err != nil || loaded.TimeFormat != global.TimeFormat || loaded.DateFormat != "numbers-mdy" { + t.Fatalf("global profile changed: %+v, %v", loaded, err) + } + appLoaded, mapped, err := store.LoadForApplication("Example Editor") + if err != nil || !mapped || appLoaded.TimeFormat != "24-hour" || appLoaded.DateFormat != "numbers-mdy" || appLoaded.DefaultVoice.Rate == nil || !appLoaded.Reading.AutomaticallyReadClipboardChanges { + t.Fatalf("application profile not saved: %+v, %t, %v", appLoaded, mapped, err) + } + if loaded.DefaultVoice.Rate != nil || loaded.Reading.AutomaticallyReadClipboardChanges { + t.Fatal("application speech shortcut overwrote global voice") + } + restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded, + settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) + sessionGlobal := loaded + sessionGlobal.DateFormat = "numbers-dmy" + if response := restarted.applyNext(ctx, sessionGlobal, false, false); !response.Accepted { + t.Fatalf("session global change rejected: %+v", response) + } + restarted.switchApplication(ctx, "Example Editor") + if restarted.settings.DateFormat != "numbers-dmy" || restarted.settings.TimeFormat != "24-hour" || !restarted.settings.Reading.AutomaticallyReadClipboardChanges { + t.Fatalf("first application load lost session inheritance: %+v", restarted.settings) + } +} diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go new file mode 100644 index 0000000..eb5f33f --- /dev/null +++ b/internal/app/preferences_apply.go @@ -0,0 +1,223 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "reflect" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func (worker *preferencesWorker) response(message string, accepted bool) preferences.Response { + settings := worker.settings + if worker.editingActive { + settings = worker.editing + } + store, err := config.NewStore(settings.Path) + if err == nil { + _, err = store.Load() + } + return preferences.Response{Values: preferences.FromSettings(settings), CanSave: err == nil, + Message: message, Accepted: accepted} +} + +func (worker *preferencesWorker) apply(ctx context.Context, request preferences.Request) preferences.Response { + base := worker.settings + if worker.editingActive { + base = worker.editing + } + next, err := request.Values.Merge(base) + if err != nil { + return worker.response("Settings were not applied: "+err.Error(), false) + } + return worker.applyNext(ctx, next, request.Save, request.Close) +} + +func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Settings, save, close bool) preferences.Response { + scopeApp := worker.activeApp + if worker.editingActive { + scopeApp = worker.editApp + } + if err := config.Validate(next); err != nil { + return worker.response("Settings were not applied: "+err.Error(), false) + } + if save && !worker.response("", false).CanSave { + return worker.response("This settings file cannot be saved by the Go preview. Use Apply for this session.", false) + } + if err := worker.applyRuntime(ctx, next); err != nil { + return worker.response(err.Error(), false) + } + if worker.editingActive { + worker.editing = next + } + if scopeApp != "" { + worker.appSettings[scopeApp] = next + } else { + oldGlobal := worker.globalSettings + for application, settings := range worker.appSettings { + worker.appSettings[application] = config.RebaseApplicationSettings(oldGlobal, next, settings) + } + worker.globalSettings = next + } + message := "Settings applied for this session." + if save { + store, err := config.NewStore(next.Path) + if err == nil { + if scopeApp != "" { + err = store.SaveForApplication(scopeApp, next) + } else { + err = store.Save(next) + } + } + if err != nil { + message = "Applied for this session, but could not save: " + err.Error() + } else { + message = "Settings applied and saved." + } + } + response := worker.response(message, true) + response.Close = close && save && message == "Settings applied and saved." + if worker.editingActive && worker.editApp != worker.activeApp { + previousApp := worker.activeApp + worker.activeApp = "" + if previousApp != "" { + worker.switchApplication(ctx, previousApp) + } + } + return response +} + +func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.Settings) error { + keyboardChanged := next.KeyboardLayout != worker.settings.KeyboardLayout || !reflect.DeepEqual(next.CthulhuModifierKeys, worker.settings.CthulhuModifierKeys) + bindingsChanged := keyboardChanged || !reflect.DeepEqual(next.KeyBindings, worker.settings.KeyBindings) + bindingsCommitted := false + if bindingsChanged { + if worker.bindings == nil { + return errors.New("Key bindings cannot be changed by this input backend.") + } + if err := worker.apply_keyboard(ctx, next, keyboardChanged); err != nil { + return errors.New("Key bindings were not applied: " + err.Error()) + } + defer func() { + if !bindingsCommitted { + rollbackCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := worker.apply_keyboard(rollbackCtx, worker.settings, keyboardChanged); err != nil { + worker.logger.Error("restore key bindings after failed preferences apply", "error", err) + } + } + }() + } + speechChanged := next.SpeechServerFactory != worker.settings.SpeechServerFactory || + !reflect.DeepEqual(next.UppercaseVoice, worker.settings.UppercaseVoice) || + !reflect.DeepEqual(next.HyperlinkVoice, worker.settings.HyperlinkVoice) || + !reflect.DeepEqual(next.SystemVoice, worker.settings.SystemVoice) || + next.HardwareSpeechDevice != worker.settings.HardwareSpeechDevice || + next.HardwareSpeechBaudRate != worker.settings.HardwareSpeechBaudRate || !reflect.DeepEqual(speechSettingsFromConfig(next), speechSettingsFromConfig(worker.settings)) || + next.UseCustomEchoVoice != worker.settings.UseCustomEchoVoice || + (next.UseCustomEchoVoice && !reflect.DeepEqual(echoSpeechSettings(next), echoSpeechSettings(worker.settings))) + if next.EnableSpeech && (speechChanged || !worker.settings.EnableSpeech) { + if message := worker.apply_speech_settings(ctx, next); message != "" { + return errors.New(message) + } + } + if next.EnableBraille != worker.settings.EnableBraille { + if worker.braille == nil { + return errors.New("Braille is unavailable in this build.") + } + if err := worker.braille.Configure(ctx, next.EnableBraille); err != nil { + return errors.New("Braille settings were not applied: " + err.Error()) + } + } + if next.EnableContractedBraille != worker.settings.EnableContractedBraille || + next.BrailleContractionTable != worker.settings.BrailleContractionTable { + if updater, ok := worker.braille.(interface{ SetContraction(bool, string) }); ok { + updater.SetContraction(next.EnableContractedBraille, next.BrailleContractionTable) + } + } + bindingsCommitted = true + worker.commit_settings(next) + return nil +} + +func (worker *preferencesWorker) apply_keyboard(ctx context.Context, settings config.Settings, keyboardChanged bool) error { + if updater, ok := worker.bindings.(interface { + SetKeyboardConfiguration(context.Context, input.KeyboardLayout, []string, []input.BindingOverride) error + }); ok { + return updater.SetKeyboardConfiguration(ctx, input.KeyboardLayout(settings.KeyboardLayout), settings.CthulhuModifierKeys, settings.KeyBindings) + } + if keyboardChanged { + return errors.New("this input backend cannot change Cthulhu keys or keyboard layout") + } + return worker.bindings.SetKeyBindings(ctx, settings.KeyBindings) +} + +func (worker *preferencesWorker) apply_speech_settings(ctx context.Context, next config.Settings) string { + var err error + + if worker.updater == nil { + return "Speech is disabled or this backend cannot change settings." + } + if updater, ok := worker.updater.(interface { + UpdateConfiguration(context.Context, config.Settings) error + }); ok { + err = updater.UpdateConfiguration(ctx, next) + } else if updater, ok := worker.updater.(speechSettingsUpdater); ok { + if next.UseCustomEchoVoice { + return "This speech backend cannot use a custom echo voice." + } + err = updater.UpdateSettings(ctx, speechSettingsFromConfig(next)) + } else { + return "This backend cannot change speech settings." + } + if err != nil { + return "Settings were not applied: " + err.Error() + } + return "" +} + +// Commit follows the existing backend updates and keyboard rollback boundary. +// It publishes settings to runtime consumers without changing their ownership. +func (worker *preferencesWorker) commit_settings(next config.Settings) { + soundChanged := soundConfiguration(next) != soundConfiguration(worker.settings) + worker.settings = next + if updater, ok := worker.braille.(interface{ SetWordWrap(bool) }); ok { + updater.SetWordWrap(next.Braille.WordWrap) + } + if worker.speechWorker != nil && !next.EnableSpeech { + worker.speechWorker.SetEnabled(false) + } + if worker.history != nil { + worker.history.setLimit(next.SpeechHistoryLimit) + if worker.refreshHistory != nil { + worker.refreshHistory() + } + } + if worker.documentReview != nil { + worker.documentReview.SetDocumentBrailleEnabled(next.EnableBraille) + } + // Configure cancels playback. An application switch or unrelated setting + // must leave the startup cue running when its sound policy is unchanged. + if worker.sound != nil && soundChanged { + worker.sound.Configure(soundConfiguration(next)) + } + if worker.geometry != nil { + worker.geometry.SetCaptureSoundGeometry(captureSoundGeometry(next)) + } + if worker.textAttributes != nil { + worker.textAttributes.SetCaptureTextAttributes(captureTextAttributes(next)) + } + if worker.live != nil { + worker.live.Store(next) + } + if worker.speechWorker != nil && next.EnableSpeech { + worker.speechWorker.SetEnabled(true) + } +} diff --git a/internal/app/preferences_feedback_test.go b/internal/app/preferences_feedback_test.go new file mode 100644 index 0000000..44a405c --- /dev/null +++ b/internal/app/preferences_feedback_test.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +type confirmationSpeaker struct{ controlledSpeaker } + +func (speaker *confirmationSpeaker) SpeakAndWait(ctx context.Context, text string, _ func()) error { + return speaker.call(ctx, text) +} + +func TestPreferencesConfirmationSurvivesAutomaticFocusUntilSpeechEnds(t *testing.T) { + speaker := &confirmationSpeaker{controlledSpeaker{calls: make(chan speechCall, 10)}} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true}) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied") + worker.Interrupt() // The window activated after OK must not cancel feedback. + worker.Enqueue(speechRequest{Text: "Window title"}) + worker.Enqueue(speechRequest{Text: "Focused control"}) + close(confirmation.done) // Represents the synth's END event, not SPEAK acknowledgement. + close(expectSpeechCall(t, &speaker.controlledSpeaker, "Window title").done) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "Focused control").done) + worker.Enqueue(speechRequest{Text: "Later output"}) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later output").done) +} + +func TestPreferencesConfirmationAllowsUserInterruption(t *testing.T) { + speaker := &confirmationSpeaker{controlledSpeaker{calls: make(chan speechCall, 10)}} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} + display.EnqueueFrame(braille.Frame{Text: "Apply", Cursor: -1}) + display.FlashConfirmation("Settings applied", config.BrailleOptions{FlashMessages: true, FlashPersistent: true}) + display.EnqueueFrame(braille.Frame{Text: "Returned focus", Cursor: -1}) + gate := &focusGate{braille: display} + output := gatedSpeech{speech: worker, gate: gate} + worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true}) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied") + worker.Enqueue(speechRequest{Text: "Discarded focus"}) + if !output.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 101}) { + t.Fatal("new user input did not interrupt") + } + if display.current().Text != "Returned focus" { + t.Fatal("new user input did not dismiss braille confirmation") + } + worker.mutex.Lock() + cleared := len(worker.pending) == 0 && worker.confirmationID == 0 + worker.mutex.Unlock() + if !cleared { + t.Fatal("user input retained speech confirmation or queued focus") + } + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + close(confirmation.done) + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) +} + +func TestPreferencesBrailleConfirmationRestoresLatestFocusOnExpiry(t *testing.T) { + display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} + display.EnqueueFrame(braille.Frame{Text: "Apply", Cursor: -1}) + if !display.FlashConfirmation("Settings applied", config.BrailleOptions{FlashMessages: true, FlashTime: 20}) { + t.Fatal("confirmation was rejected") + } + <-display.wake + display.EnqueueFrame(braille.Frame{Text: "Window title", Cursor: -1}) + display.EnqueueFrame(braille.Frame{Text: "Latest focus", Cursor: 3}) + if display.current().Text != "Settings applied" { + t.Fatal("automatic focus replaced braille confirmation") + } + select { + case <-display.wake: + if frame := display.current(); frame.Text != "Latest focus" || frame.Cursor != 3 { + t.Fatalf("restored frame = %+v", frame) + } + case <-time.After(time.Second): + t.Fatal("confirmation did not expire") + } +} + +func TestPreferencesConfirmationRetiresOnScreenLock(t *testing.T) { + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true + worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true}) + display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} + options := config.BrailleOptions{FlashMessages: true, FlashPersistent: true} + display.EnqueueFrame(braille.Frame{Text: "Apply", Cursor: -1}) + display.FlashConfirmation("Settings applied", options) + worker.SetSecureInputActive(true) + display.SetSecureInputActive(true) + if len(worker.pending) != 0 || worker.confirmationID != 0 || display.protectedFlash || display.current().Text != "" { + t.Fatal("screen lock retained preferences confirmation") + } + if display.FlashConfirmation("Late confirmation", options) || display.protectedFlash { + t.Fatal("screen lock accepted late preferences confirmation") + } +} + +func TestPreferencesConfirmationAllowsCommandBeforeRecordedInput(t *testing.T) { + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true + worker.ProtectCommand(100) + worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true}) + worker.ProtectCommand(99) + if worker.confirmationID == 0 { + t.Fatal("older command interrupted confirmation") + } + worker.ProtectCommand(101) + if worker.confirmationID != 0 || len(worker.pending) != 0 { + t.Fatal("new command did not interrupt confirmation") + } + worker.Enqueue(speechRequest{Text: "Command result"}) + if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) || len(worker.pending) != 1 { + t.Fatal("delayed initiating input discarded command result") + } +} + +func TestPreferencesApplyAnnouncesBeforeReplyAndPreservesSaveFailure(t *testing.T) { + for _, saveFailure := range []bool{false, true} { + t.Run(map[bool]string{false: "saved", true: "save failure"}[saveFailure], func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { + if saveFailure { + return os.Mkdir(settings.Path, 0700) // Runtime succeeds; subsequent disk save fails. + } + return nil + })) + messages := make(chan string, 1) + worker.announceApply = func(message string) { messages <- message } + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + stopIO := context.AfterFunc(ctx, func() { reader.CloseWithError(ctx.Err()); writer.CloseWithError(ctx.Err()) }) + defer stopIO() + requests := make(chan preferences.Request) + readDone := make(chan error, 1) + done := make(chan error, 1) + go func() { done <- worker.serve_preferences(ctx, ctx, json.NewEncoder(writer), requests, readDone) }() + values := preferences.FromSettings(settings) + rate := 65.0 + values.Rate = &rate + requests <- preferences.Request{Values: values, Save: true, Close: true} + // The pipe cannot write the reply until we start reading: feedback + // must already be queued so the helper's close cannot overtake it. + var message string + select { + case message = <-messages: + case <-ctx.Done(): + t.Fatal("feedback was not queued before the helper reply") + } + var response preferences.Response + if err := json.NewDecoder(reader).Decode(&response); err != nil || !response.Accepted { + t.Fatalf("apply response = %+v, %v", response, err) + } + if saveFailure { + if !strings.Contains(message, "could not save") || response.Close { + t.Fatalf("save failure was hidden: message=%q, close=%t", message, response.Close) + } + } else if message != "Settings applied" || !response.Close { + t.Fatalf("success: message=%q, close=%t", message, response.Close) + } + readDone <- io.EOF + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/app/preferences_preview.go b/internal/app/preferences_preview.go new file mode 100644 index 0000000..bc7b933 --- /dev/null +++ b/internal/app/preferences_preview.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func (worker *preferencesWorker) preview(ctx context.Context, request preferences.Request) preferences.Response { + next := worker.settings + if worker.editingActive { + next = worker.editing + } + next.DefaultVoice.Rate = request.Values.Rate + next.DefaultVoice.AveragePitch = request.Values.Pitch + next.DefaultVoice.Gain = request.Values.Volume + next.SpeechServerFactory = request.Values.SpeechServerFactory + next.HardwareSpeechDevice = request.Values.HardwareSpeechDevice + next.HardwareSpeechBaudRate = request.Values.HardwareSpeechBaudRate + next.SpeechOutputModule = request.Values.SpeechOutputModule + next.DefaultVoice.Family = request.Values.SpeechVoiceFamily + next.UppercaseVoice = request.Values.UppercaseVoice + next.HyperlinkVoice = request.Values.HyperlinkVoice + next.SystemVoice = request.Values.SystemVoice + // GUI feedback auditions the selected type; Cancel restores the committed + // configuration, while Apply receives the complete unmodified draft. + if kind := request.Values.VoiceType; kind != "" && kind != "default" { + next.DefaultVoice = config.EffectiveVoice(next, kind) + next.SystemVoice = config.Voice{} + } + response := worker.response("Speech settings previewed.", false) + response.Preview = true + if err := config.Validate(next); err != nil { + response.Message = "Speech settings were not previewed: " + err.Error() + return response + } + if err := worker.updateSpeech(ctx, next); err != nil { + response.Message = "Speech settings were not previewed: " + err.Error() + return response + } + response.Accepted = true + return response +} + +func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response { + response := worker.response("Speech settings restored.", false) + if err := worker.updateSpeech(ctx, worker.settings); err != nil { + response.Message = "Speech settings could not be restored: " + err.Error() + return response + } + response.Accepted = true + response.Close = true + return response +} + +func (worker *preferencesWorker) updateSpeech(ctx context.Context, next config.Settings) error { + if worker.updater == nil { + return errors.New("speech is disabled or this backend cannot change settings") + } + if updater, ok := worker.updater.(interface { + UpdateConfiguration(context.Context, config.Settings) error + }); ok { + return updater.UpdateConfiguration(ctx, next) + } + if updater, ok := worker.updater.(speechSettingsUpdater); ok { + if next.UseCustomEchoVoice { + return errors.New("this speech backend cannot use a custom echo voice") + } + return updater.UpdateSettings(ctx, speechSettingsFromConfig(next)) + } + return errors.New("this backend cannot change speech settings") +} diff --git a/internal/app/preferences_session.go b/internal/app/preferences_session.go new file mode 100644 index 0000000..a1d0451 --- /dev/null +++ b/internal/app/preferences_session.go @@ -0,0 +1,243 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/session" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +// Keep helper diagnostics bounded; GTK can repeat warnings while a display is +// unavailable. The child has no access to page content or the speech log. +type preferencesStderr struct{ data []byte } + +func (stderr *preferencesStderr) Write(p []byte) (int, error) { + const limit = 4096 + if room := limit - len(stderr.data); room > 0 { + stderr.data = append(stderr.data, p[:min(len(p), room)]...) + } + return len(p), nil +} + +func (stderr *preferencesStderr) String() string { + return string(bytes.TrimSpace(stderr.data)) +} + +func (worker *preferencesWorker) window(ctx context.Context) (result error) { + executable, err := os.Executable() + if err != nil { + return err + } + childCtx, stop := context.WithCancel(ctx) + defer stop() + command := exec.CommandContext(childCtx, executable, "--preferences-ui") + command.Env = session.GTKEnvironment() + command.WaitDelay = time.Second + in, err := command.StdinPipe() + if err != nil { + return err + } + out, err := command.StdoutPipe() + if err != nil { + in.Close() + return err + } + // Keep GTK and child startup errors off the protocol pipe, then include + // them in a failed window attempt so a broken pipe has a useful cause. + var diagnostic preferencesStderr + command.Stderr = &diagnostic + if err := command.Start(); err != nil { + in.Close() + out.Close() + return err + } + defer func() { + stop() + in.Close() + out.Close() + waitErr := command.Wait() + if ctx.Err() != nil { + return + } + if result == nil && waitErr != nil { + result = fmt.Errorf("preferences helper exited: %w", waitErr) + } + if result != nil { + if detail := diagnostic.String(); detail != "" { + result = fmt.Errorf("%w (helper exit: %v; stderr: %s)", result, waitErr, detail) + } else { + result = fmt.Errorf("%w (helper exit: %v)", result, waitErr) + } + } + }() + encoder := json.NewEncoder(in) + initial, err := worker.initial_response(childCtx) + if err != nil { + return err + } + if err := preferences.EncodeInitial(encoder, initial); err != nil { + return err + } + requests := make(chan preferences.Request, 1) + readDone := make(chan error, 1) + readerStopped := make(chan struct{}) + go read_preferences_requests(childCtx, stop, out, requests, readDone, readerStopped) + defer func() { + stop() + out.Close() + <-readerStopped + }() + return worker.serve_preferences(ctx, childCtx, encoder, requests, readDone) +} + +func (worker *preferencesWorker) initial_response(childCtx context.Context) (preferences.Response, error) { + if worker.choosingApplication { + return preferences.Response{ChooseApplication: true}, nil + } + var err error + initial := worker.response("Apply and OK save settings. Cancel discards unapplied edits.", true) + initial.Application = worker.editApp + if worker.editApp == "" { + snapshot := worker.tentacles.Snapshot() + initial.Tentacles = &snapshot + } + if !initial.CanSave { + initial.Message = "This file is read-only in the Go preview. Apply changes this session only; importing Python settings is not available yet." + } + catalogCtx, cancelCatalog := context.WithTimeout(childCtx, 4*time.Second) + initial.Catalog, err = worker.discover(catalogCtx, worker.socket) + cancelCatalog() + if err != nil { + worker.logger.Debug("discover echo voices", "error", err) + } + return initial, nil +} + +// The reader owns decoding only. Its caller closes the pipe and joins it before +// waiting for the child; cancellation also releases a full request mailbox. +func read_preferences_requests(childCtx context.Context, stop context.CancelFunc, out io.Reader, requests chan<- preferences.Request, readDone chan<- error, readerStopped chan<- struct{}) { + defer close(readerStopped) + defer stop() + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 4096), 512*1024) + for scanner.Scan() { + var request preferences.Request + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { + readDone <- err + return + } + select { + case requests <- request: + case <-childCtx.Done(): + readDone <- childCtx.Err() + return + } + } + readDone <- scanner.Err() +} + +// The worker goroutine serializes apply/preview replies and repeated-open +// requests. Preview restoration precedes pipe joins and child cleanup. +func (worker *preferencesWorker) serve_preferences(ctx, childCtx context.Context, encoder *json.Encoder, requests <-chan preferences.Request, readDone <-chan error) error { + previewActive := false + defer func() { + if !previewActive || ctx.Err() != nil { + return + } + restoreCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + if err := worker.updateSpeech(restoreCtx, worker.settings); err != nil { + worker.logger.Error("restore speech settings after preferences exit", "error", err) + } + }() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case command := <-worker.speechCommands: + if command == input.CycleProgressBarOutput { + worker.announceSpeechSetting("Close preferences before changing progress bar output with its shortcut.") + } else { + worker.announceSpeechSetting("Close preferences before using speech setting shortcuts.") + } + case <-worker.open: + // A present response raises the existing window without resetting edits. + if err := encoder.Encode(struct{ Present bool }{true}); err != nil { + return err + } + case err := <-readDone: + if errors.Is(err, io.EOF) { + return nil + } + return err + case request := <-requests: + if request.Tentacle != nil { + response := worker.manage_tentacle(childCtx, request) + if err := encoder.Encode(response); err != nil { + return err + } + continue + } + if worker.choosingApplication { + if request.Application == "" || len(request.Application) > 4096 || !worker.beginEdit(childCtx, request.Application) { + return errors.New("invalid application preferences selection") + } + worker.choosingApplication = false + initial, err := worker.initial_response(childCtx) + if err != nil { + return err + } + if err := preferences.EncodeInitial(encoder, initial); err != nil { + return err + } + continue + } + applyCtx, cancel := context.WithTimeout(childCtx, 6*time.Second) + var response preferences.Response + switch { + case request.Cancel: + response = worker.restorePreview(applyCtx) + case request.Preview: + response = worker.preview(applyCtx, request) + default: + response = worker.apply(applyCtx, request) + } + cancel() + if response.Accepted { + if request.Preview { + previewActive = true + } else { + previewActive = false + } + } + if childCtx.Err() != nil { + return nil + } + // Queue feedback before the helper restores focus or closes on OK. + if response.Accepted && !request.Preview && !request.Cancel && worker.announceApply != nil { + message := response.Message + if message == "Settings applied and saved." { + message = "Settings applied" + } + worker.announceApply(message) + } + if err := encoder.Encode(response); err != nil { + return fmt.Errorf("reply to preferences: %w", err) + } + } + } +} diff --git a/internal/app/preferences_session_test.go b/internal/app/preferences_session_test.go new file mode 100644 index 0000000..2710e7e --- /dev/null +++ b/internal/app/preferences_session_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func TestPreferencesSessionRestoresPreviewAfterRejectedApplyAndReopen(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + updates := make(chan speech.Settings, 3) + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { + updates <- value + return nil + })) + worker.announceApply = func(message string) { t.Errorf("preview or rejected Apply announced success: %q", message) } + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + stopIO := context.AfterFunc(ctx, func() { + reader.CloseWithError(ctx.Err()) + writer.CloseWithError(ctx.Err()) + }) + defer stopIO() + requests := make(chan preferences.Request) + readDone := make(chan error, 1) + done := make(chan error, 1) + go func() { + done <- worker.serve_preferences(ctx, ctx, json.NewEncoder(writer), requests, readDone) + }() + decoder := json.NewDecoder(reader) + read_response := func() preferences.Response { + t.Helper() + var response preferences.Response + if err := decoder.Decode(&response); err != nil { + t.Fatal(err) + } + return response + } + rate := 75.0 + values := preferences.FromSettings(settings) + values.Rate = &rate + select { + case requests <- preferences.Request{Values: values, Preview: true}: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if response := read_response(); !response.Accepted || !response.Preview { + t.Fatalf("preview was rejected: %+v", response) + } + if len(updates) != 1 { + t.Fatal("preview did not produce exactly one speech update") + } + if update := <-updates; update.Rate == nil || *update.Rate == 0 { + t.Fatalf("preview did not reach speech: %+v", update) + } + worker.Open() + if response := read_response(); !response.Present || response.Close { + t.Fatalf("reopen did not raise the existing window: %+v", response) + } + values.Punctuation = 99 + select { + case requests <- preferences.Request{Values: values}: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if response := read_response(); response.Accepted || response.Close { + t.Fatalf("invalid draft committed or closed: %+v", response) + } + readDone <- io.EOF + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("preferences session did not join after helper EOF") + } + if len(updates) != 1 { + t.Fatal("exit did not produce exactly one restoration update") + } + if update := <-updates; update.Rate != nil { + t.Fatalf("helper exit did not restore the server default rate: %+v", update) + } + if len(updates) != 0 || worker.settings.DefaultVoice.Rate != nil || worker.settings.VerbalizePunctuationStyle != settings.VerbalizePunctuationStyle { + t.Fatal("reopen or rejected apply committed preview settings") + } + if loaded, err := config.Load(settings.Path); err != nil || loaded.Loaded { + t.Fatalf("preview/rejection wrote settings: loaded=%t, err=%v", loaded.Loaded, err) + } +} + +func TestChooserSelectionSavesSelectedProfileWhileAnotherAppIsActive(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + store, err := config.NewStore(settings.Path) + if err != nil { + t.Fatal(err) + } + if err = store.Save(settings); err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) + worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, nil } + worker.switchApplication(ctx, "Focused Application") + worker.choosingApplication = true + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + stopIO := context.AfterFunc(ctx, func() { reader.CloseWithError(ctx.Err()); writer.CloseWithError(ctx.Err()) }) + defer stopIO() + requests := make(chan preferences.Request) + readDone := make(chan error, 1) + done := make(chan error, 1) + go func() { done <- worker.serve_preferences(ctx, ctx, json.NewEncoder(writer), requests, readDone) }() + requests <- preferences.Request{Application: "Chosen Application"} + decoder := json.NewDecoder(reader) + var initial preferences.Response + if err = decoder.Decode(&initial); err != nil { + t.Fatal(err) + } + if initial.Application != "Chosen Application" || initial.ChooseApplication || !initial.CanSave { + t.Fatalf("selected application preferences = %+v", initial) + } + values := initial.Values + values.Reading.AutomaticallyReadClipboardChanges = true + requests <- preferences.Request{Values: values, Save: true} + var response preferences.Response + if err = decoder.Decode(&response); err != nil || !response.Accepted { + t.Fatalf("apply = %+v, %v", response, err) + } + readDone <- io.EOF + if err = <-done; err != nil { + t.Fatal(err) + } + chosen, mapped, err := store.LoadForApplication("Chosen Application") + if err != nil || !mapped || !chosen.Reading.AutomaticallyReadClipboardChanges { + t.Fatalf("chosen override = %t, mapped %t, %v", chosen.Reading.AutomaticallyReadClipboardChanges, mapped, err) + } + focused, _, err := store.LoadForApplication("Focused Application") + if err != nil || focused.Reading.AutomaticallyReadClipboardChanges || worker.settings.Reading.AutomaticallyReadClipboardChanges { + t.Fatal("chosen application's preference leaked into focused application") + } +} diff --git a/internal/app/preferences_tentacles.go b/internal/app/preferences_tentacles.go new file mode 100644 index 0000000..d8da9e9 --- /dev/null +++ b/internal/app/preferences_tentacles.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (worker *preferencesWorker) manage_tentacle(ctx context.Context, request preferences.Request) preferences.Response { + response := preferences.Response{} + if worker.choosingApplication || worker.editApp != "" || request.Application != "" { + response.Message = "Tentacles can only be managed in global preferences." + return response + } + if request.Save || request.Close || request.Cancel || request.Preview || request.Tentacle == nil { + response.Message = "Tentacle management must be a separate request." + return response + } + if request.Tentacle.Action == "settings" { + if request.Tentacle.Entry != nil || request.Tentacle.URL != "" { + response.Message = "Invalid tentacle settings request." + } else if err := worker.tentacles.OpenSettings(ctx, request.Tentacle.ID); err != nil { + response.Message = err.Error() + } else { + response.Accepted = true + } + return response + } + timeout := 6 * time.Second + if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") { + timeout = 50 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := worker.tentacles.Manage(ctx, *request.Tentacle); err != nil { + response.Message = "Tentacle operation was not accepted: " + err.Error() + if errors.Is(err, tentacle.ErrRegistrySync) { + response.Accepted = true + response.Message = "Tentacle configuration applied and saved, but durability could not be confirmed." + } + } else { + response.Accepted = true + response.Message = "Tentacle configuration saved; runtime status is shown separately." + if request.Tentacle.Action == "list" { + response.Message = "Tentacle status." + } + if request.Tentacle.Action == "retry" { + response.Message = "Tentacle retry requested." + } + switch request.Tentacle.Action { + case "install": + response.Message = "Tentacle installed and saved. Enable it when you are ready." + case "install-enable": + response.Message = "Tentacle installed and enabled; runtime status is shown separately." + case "repository-add": + response.Message = "Repository added. Its tentacles are listed in the table." + case "repository-refresh": + response.Message = "Repository catalog refreshed." + case "repository-remove": + response.Message = "Repository removed. Installed tentacles and settings were retained." + } + } + snapshot := worker.tentacles.Snapshot() + response.Tentacles = &snapshot + return response +} diff --git a/internal/app/preferences_test.go b/internal/app/preferences_test.go new file mode 100644 index 0000000..3e8ad32 --- /dev/null +++ b/internal/app/preferences_test.go @@ -0,0 +1,361 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/sound" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +type settingsUpdateFunc func(context.Context, speech.Settings) error + +func TestPreferencesInitialRetainsLargeVoiceCatalog(t *testing.T) { + catalog := speech.Catalog{ + "module": make([]speech.Voice, 6000), + "other": {{Name: "Other voice", Language: "en"}}, + } + for index := range catalog["module"] { + catalog["module"][index].Name = strings.Repeat("v", 100) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) + worker.discover = func(context.Context, string) (speech.Catalog, error) { return catalog, nil } + response, err := worker.initial_response(t.Context()) + if err != nil || !reflect.DeepEqual(response.Catalog, catalog) { + t.Fatalf("initial response lost discovered voices: err=%v", err) + } +} + +func TestPreferencesWindowReportsHelperExit(t *testing.T) { + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) + worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, nil } + // The test executable does not implement --preferences-ui. It exits before + // opening a dialog, as a broken helper would on the affected machine. + err := worker.window(t.Context()) + if err == nil || !strings.Contains(err.Error(), "helper exit") || + !strings.Contains(err.Error(), "preferences-ui") { + t.Fatalf("helper startup error = %v", err) + } +} + +func (update settingsUpdateFunc) UpdateSettings(ctx context.Context, settings speech.Settings) error { + return update(ctx, settings) +} + +type recordingSoundSettings struct { + configurations []sound.Configuration +} + +type recordingBrailleSettings struct { + values []bool + contraction []bool + tables []string + err error +} + +func (recorder *recordingBrailleSettings) SetContraction(enabled bool, table string) { + recorder.contraction = append(recorder.contraction, enabled) + recorder.tables = append(recorder.tables, table) +} + +func (recorder *recordingBrailleSettings) Configure(_ context.Context, enabled bool) error { + if recorder.err != nil { + return recorder.err + } + recorder.values = append(recorder.values, enabled) + return nil +} + +func TestPreferencesBrailleApplyAndFailure(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + braille := &recordingBrailleSettings{} + worker.braille = braille + values := preferences.FromSettings(settings) + values.EnableBraille = true + values.EnableContractedBraille = true + values.BrailleContractionTable = "en-us-g2.ctb" + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || len(braille.values) != 1 || !braille.values[0] || + len(braille.contraction) != 1 || !braille.contraction[0] || braille.tables[0] != "en-us-g2.ctb" { + t.Fatalf("enable: response=%+v calls=%v", response, braille.values) + } + loaded, err := config.Load(settings.Path) + if err != nil || !loaded.EnableBraille || !loaded.EnableContractedBraille || loaded.BrailleContractionTable != "en-us-g2.ctb" { + t.Fatalf("saved braille setting: %+v, %v", loaded, err) + } + braille.err = errors.New("helper unavailable") + values.EnableBraille = false + response = worker.apply(context.Background(), preferences.Request{Values: values}) + if response.Accepted || !worker.settings.EnableBraille { + t.Fatalf("failed disable changed setting: %+v", response) + } +} + +func (recorder *recordingSoundSettings) Configure(configuration sound.Configuration) { + recorder.configurations = append(recorder.configurations, configuration) +} + +func TestPreferencesApplySoundSettingsLiveAndPersisted(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + live := &liveSettings{} + live.Store(settings) + recorder := &recordingSoundSettings{} + geometry := &fakeAdapter{} + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.live = live + worker.sound = recorder + worker.geometry = geometry + + values := preferences.FromSettings(settings) + values.EnableSound = false + values.SoundVolume = 0.8 + values.SoundSink = "pulse" + values.SoundTheme = "none" + values.PlaySoundForRole = false + values.PlaySoundForState = false + values.PlaySoundForPositionInSet = false + values.SpatializeObjectSounds = true + values.ProgressBar.OutputMode = "off" + values.ProgressBar.ReportBackground = true + values.RoleSoundPresentation = "speech_only" + + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || len(recorder.configurations) != 1 { + t.Fatalf("apply = %+v; sound updates = %+v", response, recorder.configurations) + } + wantConfiguration := sound.Configuration{Enabled: false, Sink: "pulse", Theme: "none", Volume: 0.8} + if recorder.configurations[0] != wantConfiguration { + t.Fatalf("sound configuration = %+v, want %+v", recorder.configurations[0], wantConfiguration) + } + if geometry.captureSoundGeometry { + t.Fatal("disabled sound retained synchronous event geometry capture") + } + if got := preferences.FromSettings(live.Load()); !reflect.DeepEqual(got, values) { + t.Fatalf("live values = %+v, want %+v", got, values) + } + loaded, err := config.Load(settings.Path) + if err != nil { + t.Fatal(err) + } + if got := preferences.FromSettings(loaded); !reflect.DeepEqual(got, values) { + t.Fatalf("saved values = %+v, want %+v", got, values) + } +} + +func TestCaptureSoundGeometryRequiresSpatializedObjectSounds(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSound = true + settings.SoundTheme = "default" + settings.SoundVolume = 1 + settings.SpatializeObjectSounds = true + settings.PlaySoundForRole = true + if !captureSoundGeometry(settings) { + t.Fatal("spatialized role sounds did not enable event geometry capture") + } + settings.PlaySoundForRole = false + settings.PlaySoundForState = false + if !captureSoundGeometry(settings) { + t.Fatal("spatialized position sounds did not enable event geometry capture") + } + settings.PlaySoundForPositionInSet = false + if captureSoundGeometry(settings) { + t.Fatal("geometry capture remained enabled without object sound categories") + } +} + +func TestPreferencesApplySaveAndRejectedDraft(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + var updates []speech.Settings + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { + updates = append(updates, value) + return nil + })) + values := preferences.FromSettings(settings) + rate := 75.0 + values.Rate = &rate + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || len(updates) != 1 || *updates[0].Rate != 52 { + t.Fatalf("apply = %+v; updates = %+v", response, updates) + } + if _, err := os.Stat(settings.Path); err != nil || response.Close { + t.Fatalf("Apply did not save and stay open: %v", err) + } + badRate := 100.0 + bad := values + bad.Rate = &badRate + response = worker.apply(context.Background(), preferences.Request{Values: bad, Save: true}) + if response.Accepted || len(updates) != 1 || *worker.settings.DefaultVoice.Rate != rate { + t.Fatal("invalid draft changed session") + } + response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true, Close: true}) + if !response.Accepted || !response.Close { + t.Fatalf("save = %+v", response) + } + loaded, err := config.Load(settings.Path) + if err != nil || loaded.DefaultVoice.Rate == nil || *loaded.DefaultVoice.Rate != rate { + t.Fatalf("saved settings = %+v; error %v", loaded, err) + } + if !reflect.DeepEqual(loaded.CthulhuModifierKeys, settings.CthulhuModifierKeys) || loaded.EnableSound != settings.EnableSound { + t.Fatal("saving changed unrelated settings") + } +} + +func TestPreferencesLegacyFileRemainsUntouched(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + contents := []byte("[general]\nenableSpeech = true\nunknownSetting = 'preserve me'\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + settings, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + calls := 0 + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { calls++; return nil })) + request := preferences.Request{Values: preferences.FromSettings(settings), Save: true} + if response := worker.apply(context.Background(), request); response.Accepted || response.CanSave || calls != 0 { + t.Fatalf("legacy save = %+v, calls %d", response, calls) + } + request.Save = false + if response := worker.apply(context.Background(), request); !response.Accepted || calls != 0 { + t.Fatalf("session apply = %+v", response) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != string(contents) { + t.Fatal("legacy file changed") + } +} + +func TestPreferencesBackendFailureDoesNotCommit(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return errors.New("backend rejected voice") })) + values := preferences.FromSettings(settings) + values.Punctuation = 3 + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if response.Accepted || !reflect.DeepEqual(worker.settings, settings) { + t.Fatalf("failed apply committed: %+v", response) + } + if _, err := os.Stat(settings.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("failed apply wrote a file") + } +} + +func TestPreferencesSpeechChoicesApplyAndPersist(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + var applied speech.Settings + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { + applied = value + return nil + })) + values := preferences.FromSettings(settings) + values.SpeechOutputModule = "espeak-ng" + values.SpeechVoiceFamily = &config.VoiceFamily{Name: "Generic Voice", Lang: "en", Dialect: "US", Variant: "male"} + + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || applied.OutputModule != "espeak-ng" || + applied.Language != "en-US" || applied.SynthesisVoice != "Generic Voice" { + t.Fatalf("apply = %+v; applied = %+v", response, applied) + } + if worker.settings.SpeechOutputModule != "espeak-ng" || + worker.settings.DefaultVoice.Family == nil || worker.settings.DefaultVoice.Family.Name != "Generic Voice" { + t.Fatalf("session settings = %+v", worker.settings) + } + loaded, err := config.Load(settings.Path) + if err != nil || loaded.SpeechOutputModule != "espeak-ng" || + loaded.DefaultVoice.Family == nil || loaded.DefaultVoice.Family.Name != "Generic Voice" { + t.Fatalf("loaded settings = %+v; error %v", loaded, err) + } +} + +func TestPreferencesVoicePreviewDoesNotCommitAndCancelRestores(t *testing.T) { + settings := config.DefaultSettings() + originalRate, originalPitch, originalVolume := 40.0, 4.0, 6.0 + settings.DefaultVoice.Rate = &originalRate + settings.DefaultVoice.AveragePitch = &originalPitch + settings.DefaultVoice.Gain = &originalVolume + var updates []speech.Settings + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { + updates = append(updates, value) + return nil + })) + values := preferences.FromSettings(settings) + previewRate, previewPitch, previewVolume := 75.0, 7.5, 8.0 + values.Rate = &previewRate + values.Pitch = &previewPitch + values.Volume = &previewVolume + response := worker.preview(context.Background(), preferences.Request{Values: values, Preview: true}) + if !response.Accepted || !response.Preview || len(updates) != 1 || + updates[0].Rate == nil || *updates[0].Rate != 52 || + updates[0].Pitch == nil || *updates[0].Pitch != 60 || + updates[0].Volume == nil || *updates[0].Volume != 85 { + t.Fatalf("preview = %+v; updates = %+v", response, updates) + } + if worker.settings.DefaultVoice.Rate == nil || *worker.settings.DefaultVoice.Rate != originalRate || + worker.settings.DefaultVoice.AveragePitch == nil || *worker.settings.DefaultVoice.AveragePitch != originalPitch || + worker.settings.DefaultVoice.Gain == nil || *worker.settings.DefaultVoice.Gain != originalVolume { + t.Fatal("preview committed session settings") + } + response = worker.restorePreview(context.Background()) + if !response.Accepted || !response.Close || len(updates) != 2 || + updates[1].Rate == nil || *updates[1].Rate != -18 || + updates[1].Pitch == nil || *updates[1].Pitch != -10 || + updates[1].Volume == nil || *updates[1].Volume != 55 { + t.Fatalf("restore = %+v; updates = %+v", response, updates) + } +} + +func TestPreferencesSaveFailureReportsSessionCommit(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { + // Another writer changes the file after preflight, before Store.Save. + return os.WriteFile(settings.Path, []byte("schema-version = 999\n"), 0o600) + })) + values := preferences.FromSettings(settings) + values.Punctuation = 3 + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || !strings.Contains(response.Message, "could not save") || worker.settings.VerbalizePunctuationStyle != 3 { + t.Fatalf("partial save result = %+v", response) + } + got, _ := os.ReadFile(settings.Path) + if string(got) != "schema-version = 999\n" { + t.Fatal("save replaced another writer's incompatible file") + } +} + +func TestPreferencesOpenCoalescesAndStopJoins(t *testing.T) { + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) + for range 100 { + worker.Open() + } + if len(worker.open) != 1 { + t.Fatal("open requests did not coalesce") + } + <-worker.open + ctx, cancel := context.WithCancel(context.Background()) + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + cancel() + if err := worker.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/progress_bars.go b/internal/app/progress_bars.go new file mode 100644 index 0000000..08003b8 --- /dev/null +++ b/internal/app/progress_bars.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 ( + "fmt" + "math" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +type ProgressBarEntry struct { + beep, speech float64 + beepKnown, speechKnown bool +} + +// ProgressBarHistory belongs to the event loop and survives focus changes. +// It is bounded and released with the loop. Native identities keep distinct +// bars independent even when they occupy the same screen position. +type ProgressBarHistory struct { + entries map[atspi.ObjectID]ProgressBarEntry + order []atspi.ObjectID +} + +func (history *ProgressBarHistory) report(id atspi.ObjectID, percentage float64, settings config.ProgressBarSettings) (bool, bool) { + if history.entries == nil { + history.entries = make(map[atspi.ObjectID]ProgressBarEntry) + } + entry, exists := history.entries[id] + beep := (settings.OutputMode == "beep" || settings.OutputMode == "both") && + (!entry.beepKnown || math.Abs(percentage-entry.beep) >= float64(settings.BeepPercentageInterval)) + speak := (settings.OutputMode == "speak" || settings.OutputMode == "both") && + (!entry.speechKnown || math.Abs(percentage-entry.speech) >= float64(settings.SpeechPercentageInterval)) + if !beep && !speak { + return false, false + } + if beep { + entry.beep, entry.beepKnown = percentage, true + } + if speak { + entry.speech, entry.speechKnown = percentage, true + } + if exists { + for index, candidate := range history.order { + if candidate == id { + history.order = append(history.order[:index], history.order[index+1:]...) + break + } + } + } else if len(history.order) == 32 { + delete(history.entries, history.order[0]) + history.order = history.order[1:] + } + history.entries[id] = entry + history.order = append(history.order, id) + return beep, speak +} + +// Handle progress before ordinary focused-object filtering. This path never +// changes window ownership, native focus, review or browser mode. +func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event) bool { + if !isValueChangeEvent(event.Type) || event.Source.Role != "progress bar" { + return false + } + settings := config.DefaultSettings() + if handler.live != nil { + settings = handler.live.Load() + } + if settings.ProgressBar.OutputMode == "off" || event.Source.StatesKnown && (!event.Source.Visible || !event.Source.Showing) { + // NVDA falls back to ordinary focused-control value reporting here. + return false + } + percentage, valid := presentation.ProgressPercentage(event.Source) + if !valid { + return false + } + state := handler.state + if handler.foreground != nil && event.Foreground.Epoch != state.foregroundState.Epoch { + return true + } + if handler.focusGate.Locked() { + return true + } + // Like NVDA, refresh braille only if this is already the displayed object. + if event.Source.ID() == state.current.ID() { + state.current = event.Source + state.brailleSnapshot = event.Source + if handler.brailleOutput != nil { + handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, settings.Braille, settings.TextAttributes)) + } + } + background := event.ForegroundMismatch || state.owner.window.Valid() && + (eventWindow(event) != state.owner.window || event.Source.BusName != state.owner.applicationBus) + if background && !settings.ProgressBar.ReportBackground || handler.focusGate.Suspended() && !settings.ProgressBar.ReportBackground { + return true + } + beep, speak := state.progressBars.report(event.Source.ID(), percentage, settings.ProgressBar) + request := speechRequest{Role: "progress bar", Cause: event.Type, Append: true, + Window: eventWindow(event), BypassFocusGate: settings.ProgressBar.ReportBackground} + if beep { + request.Sound = []sound.Cue{{Name: sound.ProgressCueName(percentage)}} + } + if speak { + request.Text = fmt.Sprintf("%d percent", int(percentage)) + } + if beep || speak { + handler.speech.Enqueue(request) + } + return true +} diff --git a/internal/app/progress_bars_test.go b/internal/app/progress_bars_test.go new file mode 100644 index 0000000..6e758a2 --- /dev/null +++ b/internal/app/progress_bars_test.go @@ -0,0 +1,181 @@ +// 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" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestProgressSecureEpochAndBrailleGates(t *testing.T) { + settings := config.DefaultSettings() + settings.ProgressBar.ReportBackground = true + live := &liveSettings{} + live.Store(settings) + speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} + frames := frameRecorder{frames: make(chan braille.Frame, 4)} + gate := &focusGate{} + state := &eventLoopState{} + event := atspi.NewEvent(atspi.ValueChangedEvent, atspi.Snapshot{BusName: ":1.1", Path: "/progress", Role: "progress bar", Name: "Copy", ValueKnown: true, CurrentValue: 25, MaximumValue: 100}) + state.current = event.Source + handler := &accessibilityEventHandler{state: state, live: live, speech: speech, brailleOutput: frames, focusGate: gate} + gate.secure.Store(true) + handler.handle_progress_bar(event) + if len(speech.requests) != 0 || len(frames.frames) != 0 { + t.Fatal("progress bypassed secure input") + } + gate.secure.Store(false) + handler.foreground = &titleProbeSource{} + state.foregroundState.Epoch = 2 + event.Foreground.Epoch = 1 + handler.handle_progress_bar(event) + if len(speech.requests) != 0 || len(frames.frames) != 0 { + t.Fatal("stale epoch produced progress output") + } + event.Foreground.Epoch = 2 + handler.handle_progress_bar(event) + if len(speech.requests) != 1 || len(frames.frames) != 1 { + t.Fatal("displayed progress failed to update") + } + if frame := <-frames.frames; frame.Text != "Copy 25% progress bar" { + t.Fatalf("progress braille: %+v", frame) + } + event.Source.Path, event.Source.CurrentValue = "/other-progress", 50 + handler.handle_progress_bar(event) + if len(frames.frames) != 0 { + t.Fatal("unrelated progress took over braille") + } +} + +func TestProgressShortcutCyclesNVDAOrderWithoutSaving(t *testing.T) { + settings := config.DefaultSettings() + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.live = &liveSettings{} + worker.live.Store(settings) + message := "" + worker.announce = func(text string) { message = text } + for _, step := range []struct{ mode, message string }{ + {"both", "Beep and speak progress bar updates"}, {"off", "No progress bar updates"}, + {"speak", "Speak progress bar updates"}, {"beep", "Beep for progress bar updates"}, + } { + worker.handleSpeechCommand(context.Background(), input.CycleProgressBarOutput) + if worker.live.Load().ProgressBar.OutputMode != step.mode || worker.globalSettings.ProgressBar.OutputMode != step.mode || message != step.message { + t.Fatalf("progress shortcut: mode=%s, message=%q", worker.settings.ProgressBar.OutputMode, message) + } + } + for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} { + for _, browse := range []bool{false, true} { + bindings, err := input.ResolveCommandBindings(layout, browse, nil) + chord := input.CommandChord{Symbol: 'u', CthulhuModifier: true} + if layout == input.LaptopKeyboardLayout { + chord.Modifiers = 1 + if bindings[input.CommandChord{Symbol: 'u', CthulhuModifier: true}] != input.ReviewPreviousLine { + t.Fatal("laptop previous-line review changed") + } + } + if err != nil || bindings[chord] != input.CycleProgressBarOutput { + t.Fatalf("progress binding: layout=%v browse=%v err=%v", layout, browse, err) + } + } + } +} + +func TestProgressThresholdsAreIndependentAndBidirectional(t *testing.T) { + var history ProgressBarHistory + settings := config.DefaultProgressBarSettings() + settings.OutputMode = "both" + id := atspi.NewObjectID(":1.1", "/progress/one") + for _, step := range []struct { + value float64 + beep, speak bool + }{ + {0, true, true}, {0, false, false}, {0.9, false, false}, {1, true, false}, + {9.9, true, false}, {10, false, true}, {11, true, false}, + {1, true, false}, {0, true, true}, {100, true, true}, {99, true, false}, + } { + beep, speak := history.report(id, step.value, settings) + if beep != step.beep || speak != step.speak { + t.Fatalf("%g: beep=%t speech=%t", step.value, beep, speak) + } + } + if beep, speak := history.report(atspi.NewObjectID(":1.1", "/progress/two"), 99, settings); !beep || !speak { + t.Fatal("independent bar did not report its initial value") + } + for index := 0; index < 100; index++ { + history.report(atspi.NewObjectID(":1.1", "/progress/"+string(rune('A'+index))), 0, settings) + } + if len(history.entries) != 32 || len(history.order) != 32 { + t.Fatal("progress history is unbounded") + } +} + +func TestProgressModesBackgroundAndFocusOwnership(t *testing.T) { + for _, mode := range []string{"off", "beep", "speak", "both"} { + t.Run(mode, func(t *testing.T) { + settings := config.DefaultSettings() + settings.ProgressBar.OutputMode = mode + settings.EnableSound, settings.PlaySoundForRole = false, false + live := &liveSettings{} + live.Store(settings) + speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} + state := &eventLoopState{} + state.owner = windowOwner{window: atspi.NewObjectID(":1.1", "/window"), applicationBus: ":1.1", focus: atspi.NewObjectID(":1.1", "/button")} + before := state.owner + handler := &accessibilityEventHandler{state: state, live: live, speech: speech} + event := atspi.NewEvent(atspi.ValueChangedEvent, atspi.Snapshot{BusName: ":1.1", Path: "/progress", Role: "progress bar", ValueKnown: true, CurrentValue: 50.25, MaximumValue: 100, StatesKnown: true, Visible: true, Showing: true}) + event.Window = state.owner.window + handled := handler.handle_progress_bar(event) + if mode == "off" { + if handled || len(speech.requests) != 0 { + t.Fatal("off bypassed focused-control fallback") + } + return + } + if !handled { + t.Fatal("unfocused foreground progress was not handled") + } + request := <-speech.requests + if !request.Append || request.Text != map[string]string{"beep": "", "speak": "50 percent", "both": "50 percent"}[mode] { + t.Fatalf("progress speech = %+v", request) + } + if (len(request.Sound) != 0) != (mode == "beep" || mode == "both") { + t.Fatalf("progress sounds = %+v", request.Sound) + } + if len(request.Sound) != 0 && request.Sound[0].Name != "generated-progress-tone:50.25" { + t.Fatal("fractional pitch was rounded") + } + if state.owner != before || state.focusedID.Valid() || state.current.ID().Valid() { + t.Fatal("progress stole focus") + } + event.Source.CurrentValue = 75 + event.ForegroundMismatch = true + handler.handle_progress_bar(event) + if len(speech.requests) != 0 { + t.Fatal("background reporting enabled by default") + } + settings.ProgressBar.ReportBackground = true + live.Store(settings) + handler.handle_progress_bar(event) + if len(speech.requests) != 1 { + t.Fatal("opted-in background reporting was lost") + } + <-speech.requests + event.Source.Showing = false + if handler.handle_progress_bar(event) { + t.Fatal("offscreen progress did not use focused fallback") + } + event.Source.Showing, event.Source.Indeterminate = true, true + if handler.handle_progress_bar(event) { + t.Fatal("indeterminate progress generated output") + } + }) + } +} diff --git a/internal/app/progress_native_test.go b/internal/app/progress_native_test.go new file mode 100644 index 0000000..8ab98d6 --- /dev/null +++ b/internal/app/progress_native_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestNativeProgressBars(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PROGRESS") != "1" { + t.Skip("requires private X11/AT-SPI progress fixture") + } + 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 func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + fixture := exec.CommandContext(ctx, "python3", "../../tests/gtk_progress.py") + fixture.Stderr = os.Stderr + commands, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + ready, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { commands.Close(); fixture.Process.Kill(); fixture.Wait() }() + line, err := bufio.NewReader(ready).ReadString('\n') + if err != nil || strings.TrimSpace(line) != "ready" { + t.Fatalf("fixture: %q %v", line, err) + } + focus := func(title string) { + t.Helper() + windows, err := exec.Command("xdotool", "search", "--onlyvisible", "--name", "^"+title+"$").Output() + if err != nil { + t.Fatal(err) + } + if err := exec.Command("xdotool", "windowfocus", "--sync", strings.Fields(string(windows))[0]).Run(); err != nil { + t.Fatal(err) + } + } + focus("Progress probe") + settings := config.DefaultSettings() + settings.ProgressBar.OutputMode = "both" + live := &liveSettings{} + live.Store(settings) + speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} + state := &eventLoopState{} + handler := &accessibilityEventHandler{state: state, live: live, speech: speech, logger: slog.New(slog.DiscardHandler)} + update := func(value float64, hidden ...bool) atspi.Event { + t.Helper() + command := fmt.Sprint(value) + if len(hidden) != 0 && hidden[0] { + command = "hide " + command + } + if _, err := fmt.Fprintln(commands, command); err != nil { + t.Fatal(err) + } + for { + select { + case <-ctx.Done(): + t.Fatal("progress value event missing") + return atspi.Event{} + case event := <-adapter.Events(): + if isValueChangeEvent(event.Type) && event.Source.Role == "progress bar" && event.Source.CurrentValue == value { + return event + } + } + } + } + event := update(0.5) + if event.Source.Focused || !event.Source.Visible || !event.Source.Showing || event.ForegroundMismatch { + t.Fatalf("foreground progress capture: %+v", event) + } + state.owner.window, state.owner.applicationBus = event.Window, event.Source.BusName + if !handler.handle_progress_bar(event) { + t.Fatal("foreground progress was lost") + } + request := <-speech.requests + if request.Text != "50 percent" || len(request.Sound) != 1 || request.Sound[0].Name != "generated-progress-tone:50" { + t.Fatalf("foreground progress output: %+v", request) + } + focus("Other probe") + event = update(0.75) + if !event.ForegroundMismatch { + t.Fatal("native background progress was not identified") + } + handler.handle_progress_bar(event) + if len(speech.requests) != 0 { + t.Fatal("background output enabled by default") + } + settings.ProgressBar.ReportBackground = true + live.Store(settings) + handler.handle_progress_bar(event) + request = <-speech.requests + if request.Text != "75 percent" || len(request.Sound) != 1 { + t.Fatalf("background output: %+v", request) + } + event = update(0.9, true) + if event.Source.Showing || handler.handle_progress_bar(event) { + t.Fatal("hidden progress generated automatic output") + } +} diff --git a/internal/app/progress_settings.go b/internal/app/progress_settings.go new file mode 100644 index 0000000..5cea800 --- /dev/null +++ b/internal/app/progress_settings.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import "git.stormux.org/storm/cthulhu/internal/config" + +// The preferences worker owns shortcut changes, just like GUI changes. +// Follow NVDA+U's off -> speak -> beep -> both -> off order. Saving remains +// explicit; application profiles continue to inherit unchanged global values. +func (worker *preferencesWorker) cycle_progress_output() { + next := worker.settings + message := "" + switch next.ProgressBar.OutputMode { + case "both": + next.ProgressBar.OutputMode, message = "off", "No progress bar updates" + case "off": + next.ProgressBar.OutputMode, message = "speak", "Speak progress bar updates" + case "speak": + next.ProgressBar.OutputMode, message = "beep", "Beep for progress bar updates" + default: + next.ProgressBar.OutputMode, message = "both", "Beep and speak progress bar updates" + } + if worker.activeApp != "" { + worker.appSettings[worker.activeApp] = next + } else { + for application, settings := range worker.appSettings { + worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, next, settings) + } + worker.globalSettings = next + } + worker.commit_settings(next) + worker.announceSpeechSetting(message) +} diff --git a/internal/app/prompt_presentation.go b/internal/app/prompt_presentation.go new file mode 100644 index 0000000..4871e4f --- /dev/null +++ b/internal/app/prompt_presentation.go @@ -0,0 +1,56 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +type promptPresentation struct { + state *eventLoopState + event atspi.Event + activation bool + focus bool +} + +func newPromptPresentation(state *eventLoopState, event atspi.Event) promptPresentation { + control := event.Source.Role == "button" || event.Source.Role == "tree table" || event.Source.Role == "table cell" + return promptPresentation{ + state: state, + event: event, + activation: event.Type == atspi.WindowActivatedEvent && control && event.WindowMessage != "", + focus: isFocusGainEvent(event) && control && state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow, + } +} + +func (prompt promptPresentation) focusSpeech(spoken, fallback presentation.Speech) (presentation.Speech, presentation.Speech) { + if prompt.focus && !prompt.state.pendingPromptTextAnnounced && prompt.event.WindowMessage != "" { + spoken = spoken.Prepend(prompt.event.WindowMessage, ", ") + fallback = spoken + } + return spoken, fallback +} + +func (prompt promptPresentation) appendFocus() bool { + return prompt.focus && (prompt.state.pendingPromptTextAnnounced || prompt.event.WindowMessage != "") +} + +func (prompt promptPresentation) finishFocus() { + if isFocusGainEvent(prompt.event) && (prompt.event.Source.Role == "button" || prompt.event.Source.Role == "tree table" || prompt.event.Source.Role == "table cell") { + prompt.state.pendingPromptWindow = atspi.ObjectID{} + prompt.state.pendingPromptTextAnnounced = false + } +} + +func (prompt promptPresentation) rememberWindow() { + if (prompt.event.Type == atspi.WindowCreateEvent || prompt.event.Type == atspi.WindowActivatedEvent) && + (prompt.event.Source.Role == "alert" || prompt.event.Source.Role == "frame" || prompt.activation) && + prompt.event.Window.Valid() { + prompt.state.pendingPromptWindow = prompt.event.Window + prompt.state.pendingPromptTextAnnounced = prompt.event.Source.Text != "" || prompt.activation + } +} diff --git a/internal/app/prompt_presentation_test.go b/internal/app/prompt_presentation_test.go new file mode 100644 index 0000000..ee2b76c --- /dev/null +++ b/internal/app/prompt_presentation_test.go @@ -0,0 +1,450 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestAlertMessageFinishesBeforeInitialButton(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 5)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + window := atspi.NewObjectID(":1.4", "/question") + alert := atspi.NewSnapshot(window.Path, "Question", "alert", "", "Version 0.6.0 is available. Open the releases page in your browser?") + alert.BusName = window.BusName + created := atspi.NewEvent(atspi.WindowCreateEvent, alert) + created.Window = window + adapter.events <- created + activated := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.NewSnapshot(window.Path, "Question", "alert", "", "")) + activated.Source.BusName = window.BusName + activated.Window = window + adapter.events <- activated + for _, item := range []struct{ path, name string }{{"/question/no", "No"}, {"/question/yes", "Yes"}} { + button := atspi.NewSnapshot(item.path, item.name, "button", "", "") + button.BusName = window.BusName + focused := atspi.NewEvent(atspi.FocusedStateEvent, button) + focused.Window = window + focused.Detail1 = 1 + adapter.events <- focused + } + + for index, expected := range []struct { + text string + append bool + }{ + {"Question, alert, Version 0.6.0 is available. Open the releases page in your browser?", false}, + {"No, button", true}, + {"Yes, button", false}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestSimpleFramePromptMessageFinishesBeforeButton(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + window := atspi.NewObjectID(":1.5", "/prompt") + frame := atspi.NewSnapshot(window.Path, "Alert", "frame", "", "Microsoft Windows has been found! Would you like to remove it?") + frame.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) + activated.Window = window + adapter.events <- activated + for _, item := range []struct{ path, name string }{{"/prompt/yes", "gtk-yes"}, {"/prompt/no", "gtk-no"}} { + button := atspi.NewSnapshot(item.path, item.name, "button", "", "") + button.BusName = window.BusName + focused := atspi.NewEvent(atspi.FocusedStateEvent, button) + focused.Window = window + focused.Detail1 = 1 + adapter.events <- focused + } + for index, expected := range []struct { + text string + append bool + }{ + {"Alert, frame, Microsoft Windows has been found! Would you like to remove it?", false}, + {"gtk-yes, button", true}, + {"gtk-no, button", false}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestSimplePromptRecoversMessageAtFirstButton(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.6", "/prompt") + frame := atspi.NewSnapshot(window.Path, "Alert", "frame", "", "") + frame.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) + activated.Window = window + adapter.events <- activated + button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "") + button.BusName = window.BusName + focused := atspi.NewEvent(atspi.FocusedStateEvent, button) + focused.Window = window + focused.Detail1 = 1 + focused.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?" + adapter.events <- focused + for index, expected := range []struct { + text string + append bool + }{ + {"Alert, frame", false}, + {"Microsoft Windows has been found! Would you like to remove it?, gtk-yes, button", true}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestListPromptAnnouncesQuestionBeforeChoices(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.9", "/list") + list := atspi.NewSnapshot("/list/choices", "", "tree table", "", "") + list.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, list) + activated.Window = window + activated.WindowTitle = "Select Sound Type" + activated.WindowMessage = "Which sound type would you like to use?" + adapter.events <- activated + focused := atspi.NewEvent(atspi.FocusedStateEvent, list) + focused.Window = window + focused.Detail1 = 1 + adapter.events <- focused + for index, expected := range []struct { + text string + append bool + }{ + {"Select Sound Type, Which sound type would you like to use?", false}, + {"tree table", true}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestListPromptAnnouncesQuestionWhenActivationTargetsFirstCell(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.9", "/power") + cell := atspi.NewSnapshot("/power/list/first", "Power off", "table cell", "", "") + cell.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell) + activated.Window = window + activated.WindowTitle = "I38" + activated.WindowMessage = "Power options" + adapter.events <- activated + select { + case request := <-speech.requests: + if request.Text != "I38, Power options" { + t.Fatalf("activation speech = %q", request.Text) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for list prompt") + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestListPromptRecoversQuestionOnFocus(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.10", "/list") + frame := atspi.NewSnapshot(window.Path, "Select Sound Type", "frame", "", "") + frame.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) + activated.Window = window + adapter.events <- activated + list := atspi.NewSnapshot("/list/choices", "", "tree table", "", "") + list.BusName = window.BusName + focused := atspi.NewEvent(atspi.FocusedStateEvent, list) + focused.Window = window + focused.Detail1 = 1 + focused.WindowMessage = "Which sound type would you like to use?" + adapter.events <- focused + for index, expected := range []struct { + text string + append bool + }{ + {"Select Sound Type, frame", false}, + {"Which sound type would you like to use?, tree table", true}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestFocusedButtonActivationAnnouncesPromptBeforeButton(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.7", "/prompt") + button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "") + button.BusName = window.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, button) + activated.Window = window + activated.WindowTitle = "Alert" + activated.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?" + adapter.events <- activated + focused := atspi.NewEvent(atspi.FocusedStateEvent, button) + focused.Window = window + focused.Detail1 = 1 + adapter.events <- focused + other := atspi.NewSnapshot("/prompt/no", "gtk-no", "button", "", "") + other.BusName = window.BusName + focusedOther := atspi.NewEvent(atspi.FocusedStateEvent, other) + focusedOther.Window = window + focusedOther.Detail1 = 1 + adapter.events <- focusedOther + for index, expected := range []struct { + text string + append bool + }{ + {"Alert, Microsoft Windows has been found! Would you like to remove it?", false}, + {"gtk-yes, button", true}, + {"gtk-no, button", false}, + } { + select { + case request := <-speech.requests: + if request.Text != expected.text || request.Append != expected.append { + t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestFocusedButtonActivationAfterFocusKeepsPrompt(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + window := atspi.NewObjectID(":1.8", "/prompt") + button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "") + button.BusName = window.BusName + button.Focused = true + focused := atspi.NewEvent(atspi.FocusedStateEvent, button) + focused.Window = window + focused.Detail1 = 1 + adapter.events <- focused + activated := atspi.NewEvent(atspi.WindowActivatedEvent, button) + activated.Window = window + activated.WindowTitle = "Alert" + activated.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?" + adapter.events <- activated + for index, expected := range []string{ + "gtk-yes, button", + "Alert, Microsoft Windows has been found! Would you like to remove it?", + } { + select { + case request := <-speech.requests: + if request.Text != expected { + t.Fatalf("request %d = %q, want %q", index, request.Text, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop to stop") + } +} + +func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Prompt") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Output") + if got := <-speech.spoken; got != "Output" { + t.Fatalf("text = %q", got) + } + adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) + // The caret timer must have fired before the next navigation event. + select { + case got := <-speech.spoken: + t.Fatalf("prompt replayed after insertion: %q", got) + case <-time.After(30 * time.Millisecond): + } + terminal.CaretCharacter = "P" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) + select { + case got := <-speech.spoken: + if got != "P" { + t.Fatalf("caret navigation = %q", got) + } + case <-time.After(time.Second): + t.Fatal("caret navigation was suppressed") + } + terminal.CaretCharacter = "" + terminal.Text = "Another line" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) + select { + case got := <-speech.spoken: + if got != "Another line" { + t.Fatalf("line navigation = %q", got) + } + case <-time.After(time.Second): + t.Fatal("line navigation was suppressed") + } + cancel() + <-completed +} diff --git a/internal/app/read_all.go b/internal/app/read_all.go new file mode 100644 index 0000000..4063934 --- /dev/null +++ b/internal/app/read_all.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + "strings" + "unicode" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { + if result.ReadAllContext == nil || result.ReadAllContext.Err() != nil { + return + } + text := "" + var parts []readAllPart + if result.Err != nil { + logger.Warn("read all failed", "error", result.Err) + text = "Could not read from current location." + } else { + // Keep semantic roles audible in continuous reading, without firing all + // the page's sound cues before the speech has reached those objects. + options.RoleSoundPresentation = "sound_and_speech" + // Python reads each cell once in Say All rather than expanding every + // cell into the same complete row. + reading := config.DefaultSettings().Reading + if options.Reading != nil { + reading = *options.Reading + } + reading.ReadFullRowInDocumentTable, reading.ReadFullRowInSpreadSheet = false, false + options.Reading = &reading + options.ReadFullRowInGUITable = false + for index, line := range result.ReadAllLines { + if spoken := presentation.DocumentLineSpeechWithOptions(line, options); spoken.Text != "" { + var began func() + if result.ReadAllProgress != nil { + began = func() { result.ReadAllProgress(index) } + } + values := spoken.Chunks + if len(values) == 0 { + values = []string{spoken.Text} + } + var chunks []readAllPart + for _, value := range values { + chunks = append(chunks, readAllLineParts(value, began, options.SayAllStyle)...) + began = nil + } + chunks[0].Line = document_speech_line(line) + for index := range chunks { + chunks[index].VoiceType = document_line_voice(line) + } + chunks[0].Context = line.Context + parts = append(parts, chunks...) + } + } + } + if text != "" || len(parts) != 0 { + speech.Enqueue(speechRequest{Text: text, ReadAll: parts, Role: "system", Cause: "read-all", context: result.ReadAllContext}) + } +} + +// Keep each source line's first callback attached to its first spoken chunk. +// The review worker tracks line positions, so sentence mode splits within a +// line and never merges lines whose separate progress updates drive the caret. +func readAllLineParts(text string, began func(), style string) []readAllPart { + if style != "sentence" { + return []readAllPart{{Text: text, Began: began}} + } + runes := []rune(text) + var parts []readAllPart + start := 0 + for index, value := range runes { + if value != '.' && value != '!' && value != '?' { + continue + } + if index+1 < len(runes) && !unicode.IsSpace(runes[index+1]) { + continue + } + chunk := strings.TrimSpace(string(runes[start : index+1])) + if chunk != "" { + parts = append(parts, readAllPart{Text: chunk, Began: began}) + began = nil + } + start = index + 1 + } + if tail := strings.TrimSpace(string(runes[start:])); tail != "" { + parts = append(parts, readAllPart{Text: tail, Began: began}) + } + return parts +} diff --git a/internal/app/read_all_test.go b/internal/app/read_all_test.go new file mode 100644 index 0000000..d874434 --- /dev/null +++ b/internal/app/read_all_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/logging" +) + +type continuousSpeaker struct { + started chan string + finish chan struct{} +} + +func TestReadAllSentencePartsKeepLineProgress(t *testing.T) { + progress := 0 + parts := readAllLineParts("First sentence. Second sentence! Last words", func() { progress++ }, "sentence") + if len(parts) != 3 || parts[0].Text != "First sentence." || + parts[1].Text != "Second sentence!" || parts[2].Text != "Last words" { + t.Fatalf("sentence parts = %#v", parts) + } + if parts[0].Began == nil || parts[1].Began != nil || parts[2].Began != nil { + t.Fatalf("line progress callbacks = %#v", parts) + } + parts[0].Began() + if progress != 1 { + t.Fatalf("progress = %d", progress) + } + line := readAllLineParts("First sentence. Second sentence!", nil, "line") + if len(line) != 1 || line[0].Text != "First sentence. Second sentence!" { + t.Fatalf("line part = %#v", line) + } +} + +func TestReadAllContextArrivesAtItsLine(t *testing.T) { + speaker := &continuousSpeaker{started: make(chan string, 6), finish: make(chan struct{}, 2)} + speaker.finish <- struct{}{} + speaker.finish <- struct{}{} + reading := config.DefaultSettings().Reading + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.reading = func() config.ReadingSettings { return reading } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} + request := speechRequest{ReadAll: []readAllPart{ + {Text: "Before", Context: []atspi.Snapshot{}}, + {Text: "Inside", Context: []atspi.Snapshot{list}}, + }} + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + for _, want := range []string{"Before", "Items list", "Inside"} { + if got := <-speaker.started; got != want { + t.Fatalf("spoken %q, want %q", got, want) + } + } + reading.SayAllContextList = false + worker.speechContext = nil + speaker.finish <- struct{}{} + if err := worker.deliver(context.Background(), speechRequest{ReadAll: []readAllPart{{Text: "Inside again", Context: []atspi.Snapshot{list}}}}); err != nil { + t.Fatal(err) + } + if got := <-speaker.started; got != "Inside again" || len(speaker.started) != 0 { + t.Fatalf("disabled list context speech = %q; queued = %d", got, len(speaker.started)) + } +} + +func TestReadAllDiagnosticsIncludeOnlyDeliveredParts(t *testing.T) { + var output bytes.Buffer + speaker := &continuousSpeaker{started: make(chan string, 3), finish: make(chan struct{}, 1)} + speaker.finish <- struct{}{} + worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), speaker) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + request := speechRequest{Cause: "read-all", Role: "system", ReadAll: []readAllPart{ + {Text: "First line"}, {Text: "Second line", Began: cancel}, {Text: "Unread line"}, + }} + if err := worker.deliver(ctx, request); !errors.Is(err, context.Canceled) { + t.Fatalf("interrupted reading: %v", err) + } + for _, expected := range []string{`"utterance":"First line"`, `"utterance":"Second line"`, `"read_all_index":1`} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("reading diagnostics missing %s: %s", expected, output.String()) + } + } + if strings.Contains(output.String(), "Unread line") { + t.Fatalf("logged reading which never reached the backend: %s", output.String()) + } +} + +func (*continuousSpeaker) Connect(context.Context) error { return nil } +func (*continuousSpeaker) Close(context.Context) error { return nil } +func (*continuousSpeaker) Cancel(context.Context) error { return nil } +func (speaker *continuousSpeaker) Speak(_ context.Context, text string) error { + speaker.started <- text + return nil +} +func (speaker *continuousSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { + if began != nil { + began() + } + speaker.started <- text + select { + case <-speaker.finish: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) { + for _, cause := range []string{"key", "focus"} { + t.Run(cause, func(t *testing.T) { + speaker := &continuousSpeaker{started: make(chan string, 4), finish: make(chan struct{})} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.Start(context.Background()) + defer worker.Stop(context.Background()) + ctx := worker.BeginReadAll(context.Background(), 100) + // The initiating key can reach XRecord after command dispatch. + if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 100}) || ctx.Err() != nil { + t.Fatal("read all canceled by its initiating key") + } + progress := make(chan int, 3) + worker.Enqueue(speechRequest{Cause: "read-all", context: ctx, ReadAll: []readAllPart{ + {Text: "First", Began: func() { progress <- 1 }}, + {Text: "Second", Began: func() { progress <- 2 }}, + {Text: "Third", Began: func() { progress <- 3 }}, + }}) + waitText := func(want string) { + t.Helper() + select { + case got := <-speaker.started: + if got != want { + t.Fatalf("speech %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + waitText("First") + if <-progress != 1 { + t.Fatal("wrong starting position") + } + select { + case <-speaker.started: + t.Fatal("advanced before speech completed") + default: + } + speaker.finish <- struct{}{} + waitText("Second") + if <-progress != 2 { + t.Fatal("wrong second position") + } + if cause == "key" { + if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) { + t.Fatal("new key ignored") + } + } + worker.Enqueue(speechRequest{Text: "New focus", Cause: "focus:"}) + waitText("New focus") + if ctx.Err() == nil { + t.Fatal("read all was not canceled") + } + select { + case <-progress: + t.Fatal("reading position advanced after interruption") + default: + } + }) + } +} diff --git a/internal/app/reading_context.go b/internal/app/reading_context.go new file mode 100644 index 0000000..c9ea6d1 --- /dev/null +++ b/internal/app/reading_context.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +func (worker *speechWorker) context_messages(ancestors []atspi.Snapshot) []string { + return worker.context_messages_for(ancestors, false) +} + +func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, sayAll bool) []string { + if ancestors == nil || worker.reading == nil { + return nil + } + current := make([]atspi.Snapshot, 0, len(ancestors)) + for _, ancestor := range ancestors { + if presentation.ContextType(ancestor) != "" { + current = append(current, ancestor) + } + } + previous := worker.speechContext + worker.speechContext = current + settings := worker.reading() + contains := func(values []atspi.Snapshot, candidate atspi.Snapshot) bool { + for _, item := range values { + if item.ID() == candidate.ID() && item.Role == candidate.Role && item.XMLRoles == candidate.XMLRoles { + return true + } + } + return false + } + var messages []string + panelMessages := make(map[string]bool) + appendMessage := func(item atspi.Snapshot, message string) { + // Keep every container identity for later transitions, but say an + // identical panel label only once in this transition. + if presentation.ContextType(item) == "panel" { + if panelMessages[message] { + return + } + panelMessages[message] = true + } + messages = append(messages, message) + } + enabled := presentation.ContextEnabled + verbosity := 0 + if worker.verbosity != nil { + verbosity = worker.verbosity() + } + if sayAll { + enabled = presentation.SayAllContextEnabled + } + for _, item := range previous { + if !contains(current, item) && enabled(presentation.ContextType(item), settings) { + appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".") + } + } + for index := len(current) - 1; index >= 0; index-- { + item := current[index] + if !contains(previous, item) && enabled(presentation.ContextType(item), settings) { + appendMessage(item, presentation.ContextCountLabel(item, verbosity)) + } + } + return messages +} + +func (worker *speechWorker) deliver_context(ctx context.Context, request speechRequest) error { + return worker.deliver_context_for(ctx, request.Context, false) +} + +func (worker *speechWorker) deliver_context_for(ctx context.Context, ancestors []atspi.Snapshot, sayAll bool) error { + for _, message := range worker.context_messages_for(ancestors, sayAll) { + worker.logDelivery(ctx, speechRequest{Role: "system", Cause: "reading-context", Append: true}, message, -1) + if voiced, ok := worker.speaker.(interface { + SpeakVoice(context.Context, string, string) error + }); ok { + if err := voiced.SpeakVoice(ctx, message, "system"); err != nil { + return err + } + } else if err := worker.speaker.Speak(ctx, message); err != nil { + return err + } + } + return nil +} + +func document_line_voice(line atspi.DocumentLine) string { + for _, content := range line.Contents { + if content.Snapshot.Role == "link" { + return "hyperlink" + } + } + return "default" +} + +func ancestor_voice(ancestors []atspi.Snapshot) string { + for _, ancestor := range ancestors { + if ancestor.Role == "link" { + return "hyperlink" + } + } + return "" +} diff --git a/internal/app/reading_preferences_test.go b/internal/app/reading_preferences_test.go new file mode 100644 index 0000000..e47d996 --- /dev/null +++ b/internal/app/reading_preferences_test.go @@ -0,0 +1,307 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "log/slog" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func TestIndentationTracksRawSpacingAndModes(t *testing.T) { + reading := config.DefaultSettings().Reading + reading.EnableIndentation = true + reading.IndentationPresentationMode = "both" + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.reading = func() config.ReadingSettings { return reading } + var cues []sound.Cue + worker.indentationCue = func(cue sound.Cue) { cues = append(cues, cue) } + id := atspi.ObjectID{BusName: ":1.42", Path: "/editor"} + for _, tc := range []struct { + raw, spoken, want string + frequency int + }{ + {"\t text", "text", "indentation 5 columns, text", 400}, + {"\t other", "other", "other", 0}, + {" text", "text", "indentation 2 columns, text", 210}, + {"text", "text", "indentation 0 columns, text", 150}, + {"\u00a0text", "text", "indentation 1 column, text", 280}, + {" text", "text", "text", 0}, + } { + cues = nil + if got := worker.line_text(tc.spoken, &speechLine{Object: id, Raw: tc.raw}); got != tc.want { + t.Fatalf("%q: %q, want %q", tc.raw, got, tc.want) + } + if tc.frequency == 0 { + if len(cues) != 0 { + t.Fatal("unchanged indentation beeped") + } + } else if len(cues) != 1 || cues[0].Name != sound.IndentationCueName(tc.frequency) { + t.Fatalf("indentation cue = %+v", cues) + } + } + reading.IndentationPresentationMode = "beeps" + if got := worker.line_text("text", &speechLine{Object: id, Raw: " text"}); got != "text" { + t.Fatal("beeps mode spoke indentation") + } + reading.OnlySpeakDisplayedText = true + cues = nil + if got := worker.line_text("text", &speechLine{Object: id, Raw: " text"}); got != "text" || len(cues) != 0 { + t.Fatal("displayed-text mode presented indentation") + } + reading.OnlySpeakDisplayedText, reading.EnableIndentation = false, false + if got := worker.line_text("text", &speechLine{Object: id, Raw: "text"}); got != "text" { + t.Fatal("disabled indentation spoke") + } +} + +func TestNavigationContextFlagsAndTransitions(t *testing.T) { + reading := config.DefaultSettings().Reading + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.reading = func() config.ReadingSettings { return reading } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} + panel := atspi.Snapshot{BusName: ":1.42", Path: "/panel", Role: "panel", Name: "Options"} + if got := worker.context_messages([]atspi.Snapshot{list, panel}); !reflect.DeepEqual(got, []string{"Options panel", "Items list"}) { + t.Fatalf("enter context = %v", got) + } + if got := worker.context_messages([]atspi.Snapshot{list, panel}); len(got) != 0 { + t.Fatal("unchanged context repeated") + } + reading.SpeakContextList = false + if got := worker.context_messages([]atspi.Snapshot{panel}); len(got) != 0 { + t.Fatal("disabled list exit announced") + } + if got := worker.context_messages([]atspi.Snapshot{}); !reflect.DeepEqual(got, []string{"Leaving Options panel."}) { + t.Fatalf("exit context = %v", got) + } + reading.OnlySpeakDisplayedText = true + if got := worker.context_messages([]atspi.Snapshot{list, panel}); len(got) != 0 { + t.Fatal("displayed-text mode announced context") + } +} + +func TestListContextCountFollowsSpeechVerbosity(t *testing.T) { + reading := config.DefaultSettings().Reading + verbosity := 1 + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.reading = func() config.ReadingSettings { return reading } + worker.verbosity = func() int { return verbosity } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Tasks", ChildCount: 3, ChildCountKnown: true} + if got := worker.context_messages([]atspi.Snapshot{list}); !reflect.DeepEqual(got, []string{"Tasks, 3 items"}) { + t.Fatalf("verbose list context = %v", got) + } + worker.context_messages([]atspi.Snapshot{}) + verbosity = 0 + if got := worker.context_messages([]atspi.Snapshot{list}); !reflect.DeepEqual(got, []string{"Tasks list"}) { + t.Fatalf("brief list context = %v", got) + } +} + +func TestSayAllContextFlagsAreIndependentOfNavigation(t *testing.T) { + for _, test := range []struct { + role string + disable func(*config.ReadingSettings) + }{ + {"block quote", func(r *config.ReadingSettings) { r.SayAllContextBlockquote = false }}, + {"list", func(r *config.ReadingSettings) { r.SayAllContextList = false }}, + {"table", func(r *config.ReadingSettings) { r.SayAllContextTable = false }}, + {"panel", func(r *config.ReadingSettings) { r.SayAllContextPanel = false }}, + {"landmark", func(r *config.ReadingSettings) { r.SayAllContextLandmark = false }}, + {"form", func(r *config.ReadingSettings) { r.SayAllContextNonLandmarkForm = false }}, + } { + reading := config.DefaultSettings().Reading + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.reading = func() config.ReadingSettings { return reading } + ancestor := atspi.Snapshot{BusName: ":1.42", Path: "/context", Role: test.role} + if got := worker.context_messages_for([]atspi.Snapshot{ancestor}, true); len(got) != 1 { + t.Fatalf("%s Say All context = %v", test.role, got) + } + test.disable(&reading) + worker.speechContext = nil + if got := worker.context_messages_for([]atspi.Snapshot{ancestor}, true); len(got) != 0 { + t.Fatalf("%s disabled Say All context = %v", test.role, got) + } + worker.speechContext = nil + if got := worker.context_messages([]atspi.Snapshot{ancestor}); len(got) != 1 { + t.Fatalf("%s navigation context changed = %v", test.role, got) + } + } +} + +func TestNavigationContextCollapsesRepeatedPanels(t *testing.T) { + reading := config.DefaultSettings().Reading + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.reading = func() config.ReadingSettings { return reading } + panels := make([]atspi.Snapshot, 5) + for index := range panels { + panels[index] = atspi.Snapshot{BusName: ":1.42", Path: fmt.Sprintf("/panel/%d", index), Role: "panel"} + } + check := func(ancestors []atspi.Snapshot, want []string) { + t.Helper() + if got := worker.context_messages(ancestors); !reflect.DeepEqual(got, want) { + t.Fatalf("context = %v, want %v", got, want) + } + } + check(panels, []string{"panel"}) + check(panels, nil) + // Unknown context must not discard remembered container identities. + check(nil, nil) + check(panels, nil) + check(panels[4:], []string{"Leaving panel."}) + check(panels[4:], nil) + check([]atspi.Snapshot{}, []string{"Leaving panel."}) + check(panels, []string{"panel"}) + check([]atspi.Snapshot{}, []string{"Leaving panel."}) + + panels[0].Name, panels[1].Name = "Inner", "Outer" + panels[2].Role = "grouping" + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} + ancestors := append([]atspi.Snapshot{list}, panels...) + check(ancestors, []string{"panel", "Outer panel", "Inner panel", "Items list"}) + check([]atspi.Snapshot{}, []string{"Leaving Items list.", "Leaving Inner panel.", "Leaving Outer panel.", "Leaving panel."}) + reading.SpeakContextPanel = false + check(ancestors, []string{"Items list"}) + check([]atspi.Snapshot{}, []string{"Leaving Items list."}) +} + +func TestDeletedCharacterRetainsCaseAndEchoRouting(t *testing.T) { + for _, custom := range []bool{false, true} { + settings := config.DefaultSettings() + settings.UseCustomEchoVoice = custom + live := &liveSettings{} + live.Store(settings) + queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + state := &eventLoopState{} + presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} + snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true} + for _, tc := range []struct { + payload, text string + character bool + }{ + {"A", "A", true}, {"a", "a", true}, {"É", "É", true}, {"é", "é", true}, + {"!", "!", true}, {" ", "space", false}, {"\n", "blank", false}, {"AB", "AB", false}, + } { + presenter.speak(atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, tc.payload)) + request := <-queue.requests + <-queue.spoken + if request.Text != tc.text || request.Character != tc.character || request.CustomEcho != (custom && tc.character) { + t.Fatalf("delete %q, custom=%v: %+v", tc.payload, custom, request) + } + } + } +} + +type voiceConfigurationRecorder struct { + settings config.Settings + calls int +} + +func (recorder *voiceConfigurationRecorder) UpdateConfiguration(_ context.Context, settings config.Settings) error { + recorder.settings = settings + recorder.calls++ + return nil +} + +func TestSpecialVoiceApplyPreviewAndCancel(t *testing.T) { + settings := config.DefaultSettings() + updater := &voiceConfigurationRecorder{} + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater) + values := preferences.FromSettings(settings) + pitch := 3.0 + values.HyperlinkVoice.AveragePitch = &pitch + values.VoiceType = "hyperlink" + if response := worker.preview(context.Background(), preferences.Request{Values: values}); !response.Accepted { + t.Fatal(response.Message) + } + if updater.settings.DefaultVoice.AveragePitch == nil || *updater.settings.DefaultVoice.AveragePitch != pitch || worker.settings.DefaultVoice.AveragePitch != nil { + t.Fatal("preview changed committed/default draft voice") + } + if response := worker.restorePreview(context.Background()); !response.Accepted || updater.settings.DefaultVoice.AveragePitch != nil { + t.Fatal("Cancel did not restore committed voice") + } + if response := worker.apply(context.Background(), preferences.Request{Values: values}); !response.Accepted { + t.Fatal(response.Message) + } + if updater.calls != 3 || *updater.settings.HyperlinkVoice.AveragePitch != pitch || updater.settings.DefaultVoice.AveragePitch != nil { + t.Fatal("special voice edit did not reach runtime independently") + } +} + +func TestTypedCapitalRetainsCharacterAndCustomEcho(t *testing.T) { + settings := config.DefaultSettings() + settings.UseCustomEchoVoice = true + live := &liveSettings{} + live.Store(settings) + queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} + state := &eventLoopState{} + state.presentationOptions = presentationSettingsFromConfig(settings, false) + presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} + snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true} + presenter.speak(atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "É")) + request := <-queue.requests + if request.Text != "É" || !request.Character || !request.CustomEcho { + t.Fatalf("capital insertion routing = %+v", request) + } + // Document spelling must retain case after replacing letters with words. + set_review_spelling(&request, "Aa", review.FlatPhonetic) + if !reflect.DeepEqual(request.SpellingVoices, []string{"uppercase", ""}) { + t.Fatal("phonetic spelling lost uppercase voice") + } +} + +func TestBriefNotificationSpeechPreservesDetailedHistory(t *testing.T) { + settings := config.DefaultSettings() + live := &liveSettings{} + queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} + history := notification.NewPresenter() + announcer := notificationAnnouncer{presenter: history, speech: queue, live: live} + entry := notification.Entry{Message: "Update: more details", Summary: "Update", Body: "more details"} + for _, detailed := range []bool{false, true} { + settings.Reading.MessagesAreDetailed = detailed + live.Store(settings) + announcer.Announce(entry) + request := <-queue.requests + want := entry.Summary + if detailed { + want = entry.Message + } + if request.Text != want { + t.Fatalf("detailed=%v: %q", detailed, request.Text) + } + } + if entries := history.Entries(); len(entries) == 0 || entries[0].Message != entry.Message { + t.Fatal("brief speech discarded notification details") + } +} + +func TestReadAllDoesNotExpandEveryCellToItsRow(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list"} + line := atspi.DocumentLine{Context: []atspi.Snapshot{list}} + for _, name := range []string{"One", "Two"} { + line.Contents = append(line.Contents, atspi.DocumentContent{Text: name, Snapshot: atspi.Snapshot{ + Path: "/" + name, Role: "table cell", TablePath: "/table", TableRowText: "One, Two", + }}) + } + result := review.Result{ReadAllContext: context.Background(), ReadAllLines: []atspi.DocumentLine{line}} + presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions()) + request := <-queue.requests + if len(request.ReadAll) != 2 || request.ReadAll[0].Text != "One" || request.ReadAll[1].Text != "Two" { + t.Fatalf("Say All repeated row: %+v", request.ReadAll) + } + if len(request.ReadAll[0].Context) != 1 || request.ReadAll[0].Context[0].ID() != list.ID() { + t.Fatalf("Say All lost line context: %+v", request.ReadAll[0]) + } +} diff --git a/internal/app/reading_speech.go b/internal/app/reading_speech.go new file mode 100644 index 0000000..8ecd5ec --- /dev/null +++ b/internal/app/reading_speech.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "unicode" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +// Apply pronunciation choices to the final text handed to the speech backend. +// Python Cthulhu splits lower-to-upper case boundaries and numeric words. +func readingSpeechText(text string, settings config.ReadingSettings) string { + if settings.SpeakMultiCaseStringsAsWords { + var result strings.Builder + var previous rune + for _, current := range text { + if unicode.IsLower(previous) && unicode.IsUpper(current) { + result.WriteByte(' ') + } + result.WriteRune(current) + previous = current + } + text = result.String() + } + if !settings.SpeakNumbersAsDigits { + return text + } + var result strings.Builder + var word []rune + flush := func() { + if len(word) == 0 { + return + } + allNumbers := true + for _, value := range word { + if !unicode.IsNumber(value) { + allNumbers = false + break + } + } + for index, value := range word { + if allNumbers && index != 0 { + result.WriteByte(' ') + } + result.WriteRune(value) + } + word = word[:0] + } + for _, value := range text { + if unicode.IsLetter(value) || unicode.IsNumber(value) || value == '_' { + word = append(word, value) + } else { + flush() + result.WriteRune(value) + } + } + flush() + return result.String() +} + +// Replace complete words once, without feeding replacements back into the +// dictionary. This keeps punctuation and spacing from the accessible text. +func pronunciationSpeechText(text string, entries []config.PronunciationEntry) string { + if len(entries) == 0 { + return text + } + dictionary := make(map[string]string, len(entries)) + for _, entry := range entries { + dictionary[strings.ToLower(entry.Word)] = entry.Replacement + } + var output strings.Builder + var word []rune + flush := func() { + if len(word) == 0 { + return + } + original := string(word) + if replacement, ok := dictionary[strings.ToLower(original)]; ok { + output.WriteString(replacement) + } else { + output.WriteString(original) + } + word = word[:0] + } + for _, current := range text { + if unicode.IsLetter(current) || unicode.IsNumber(current) || current == '_' { + word = append(word, current) + } else { + flush() + output.WriteRune(current) + } + } + flush() + return output.String() +} diff --git a/internal/app/reading_speech_test.go b/internal/app/reading_speech_test.go new file mode 100644 index 0000000..4bfd720 --- /dev/null +++ b/internal/app/reading_speech_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestReadingSpeechText(t *testing.T) { + for _, test := range []struct { + name string + settings config.ReadingSettings + input string + want string + }{ + {"unchanged", config.ReadingSettings{}, "saveFile 123", "saveFile 123"}, + {"multicase", config.ReadingSettings{SpeakMultiCaseStringsAsWords: true}, "saveFile and HTTPServer", "save File and HTTPServer"}, + {"digits", config.ReadingSettings{SpeakNumbersAsDigits: true}, "Room 123, A12, ٤٥", "Room 1 2 3, A12, ٤ ٥"}, + {"both", config.ReadingSettings{SpeakNumbersAsDigits: true, SpeakMultiCaseStringsAsWords: true}, "orderNumber 42", "order Number 4 2"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := readingSpeechText(test.input, test.settings); got != test.want { + t.Fatalf("speech text = %q, want %q", got, test.want) + } + }) + } +} + +func TestPronunciationPreservesCharacterReadingAndSpelling(t *testing.T) { + entries := []config.PronunciationEntry{{Word: "a", Replacement: "article"}, {Word: "space", Replacement: "blank"}} + for _, test := range []struct { + name string + request speechRequest + want []string + }{ + {"character", speechRequest{Text: "A", Character: true}, []string{"A"}}, + {"echo", speechRequest{Text: "a", Character: true, CustomEcho: true}, []string{"a"}}, + {"spelling", func() speechRequest { + request := speechRequest{Text: "Aa"} + set_review_spelling(&request, "Aa", review.FlatSpell) + return request + }(), []string{"A", "a"}}, + {"word", speechRequest{Text: "a"}, []string{"article"}}, + {"character name", speechRequest{Text: "space", Character: true}, []string{"blank"}}, + } { + t.Run(test.name, func(t *testing.T) { + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.textTransform = func(text string) string { return pronunciationSpeechText(text, entries) } + if err := worker.deliver(context.Background(), test.request); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(speaker.parts, test.want) { + t.Fatalf("delivered %q, want %q", speaker.parts, test.want) + } + }) + } +} + +func TestSpeechHistoryReplayDoesNotApplyPronunciationTwice(t *testing.T) { + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.history = newSpeechHistory(10) + worker.textTransform = func(text string) string { + return pronunciationSpeechText(text, []config.PronunciationEntry{ + {Word: "SQL", Replacement: "sequel"}, {Word: "sequel", Replacement: "next"}, + }) + } + if err := worker.deliver(context.Background(), speechRequest{Text: "SQL"}); err != nil { + t.Fatal(err) + } + recorded := worker.history.snapshot()[0] + if err := worker.deliver(context.Background(), speechRequest{Text: recorded, Role: "table cell", ExcludeFromHistory: true}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(speaker.parts, []string{"sequel", "sequel"}) { + t.Fatalf("history replay changed recorded speech: %q", speaker.parts) + } +} + +func TestPronunciationSpeechTextReplacesWholeWordsOnce(t *testing.T) { + entries := []config.PronunciationEntry{{Word: "SQL", Replacement: "sequel"}, {Word: "sequel", Replacement: "next"}, {Word: "café", Replacement: "coffee"}} + input := "SQL, sql; SQLite café!" + if got := pronunciationSpeechText(input, entries); got != "sequel, sequel; SQLite coffee!" { + t.Fatalf("pronunciation = %q", got) + } +} diff --git a/internal/app/remote.go b/internal/app/remote.go new file mode 100644 index 0000000..26819cd --- /dev/null +++ b/internal/app/remote.go @@ -0,0 +1,153 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "slices" + "strings" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/remote" + "git.stormux.org/storm/cthulhu/internal/wine" + "github.com/godbus/dbus/v5" +) + +type remoteSpeech struct { + speech speechEnqueuer + braille interface { + EnqueueMessage(string) bool + } + wine *wineEventPresenter + wineReview *wine.ReviewRegistry + showPreferences func() + quit func() + runtimeSnapshot func() string + live *liveSettings + preferences *preferencesWorker +} + +func (presenter remoteSpeech) RegisterProvider(sender string, path dbus.ObjectPath, version uint32) bool { + return presenter.wineReview != nil && presenter.wineReview.RegisterProvider(sender, path, version) +} +func (presenter remoteSpeech) PresentFocus(sender string, notice wine.ReviewFocusNotice) bool { + return presenter.wineReview != nil && presenter.wineReview.PresentFocus(sender, notice) +} +func (presenter remoteSpeech) BindWineReviewConnection(connection *dbus.Conn) { + if presenter.wineReview != nil { + presenter.wineReview.BindWineReviewConnection(connection) + } +} + +func (presenter remoteSpeech) ShowPreferences() bool { + if presenter.showPreferences == nil { + return false + } + presenter.showPreferences() + return true +} + +func (presenter remoteSpeech) Quit() bool { + if presenter.quit == nil { + return false + } + presenter.quit() + return true +} + +func (presenter remoteSpeech) RuntimeStateSnapshot() string { + if presenter.runtimeSnapshot == nil { + return "" + } + return presenter.runtimeSnapshot() +} + +func (presenter remoteSpeech) PresentAccessibleEvent(event remote.AccessibleEvent) bool { + return presenter.wine != nil && presenter.wine.present_event(event, presenter.presentWineEditor) +} + +func (presenter remoteSpeech) PresentMessage(message string) bool { + return presenter.presentMessage(message, "remote-present-message") +} + +func (presenter remoteSpeech) presentMessage(message, cause string) bool { + return presenter.presentRequest(speechRequest{ + Text: message, Role: "system", Cause: cause, Append: true, + }) +} + +func (presenter remoteSpeech) presentWineEditor(event remote.AccessibleEvent) bool { + // These action kinds are part of the helper editor protocol. Keep + // character identity for navigation and Backspace as well as typing. + kinds := strings.Fields(event.States) + inserted := slices.Contains(kinds, "kind=8") + character := inserted || utf8.RuneCountInString(event.Name) == 1 + custom := false + if character && presenter.live != nil && (inserted || slices.Contains(kinds, "kind=7")) { + custom = customEcho(presenter.live.Load(), "character") + } + return presenter.presentRequest(speechRequest{ + Text: event.Name, Role: "system", Cause: "wine-accessible-event", Append: true, + Character: character, CustomEcho: custom, + }) +} + +func (presenter remoteSpeech) presentRequest(request speechRequest) bool { + if request.Text == "" { + return false + } + accepted := false + if presenter.speech != nil { + presenter.speech.Enqueue(request) + accepted = true + } + if presenter.braille != nil && presenter.braille.EnqueueMessage(request.Text) { + accepted = true + } + return accepted +} + +func (presenter remoteSpeech) SpeakText(text string, interrupt bool) bool { + if presenter.speech == nil || text == "" { + return false + } + if interrupt { + presenter.speech.Interrupt() + } + presenter.speech.Enqueue(speechRequest{ + Text: text, + Role: "system", + Cause: "remote-speak-text", + Append: true, + }) + return true +} + +func (presenter remoteSpeech) BrailleMessage(text string) bool { + if presenter.braille == nil || text == "" { + return false + } + return presenter.braille.EnqueueMessage(text) +} + +func (presenter remoteSpeech) CancelSpeech() bool { + if presenter.speech == nil { + return false + } + presenter.speech.Interrupt() + return true +} diff --git a/internal/app/remote_control.go b/internal/app/remote_control.go new file mode 100644 index 0000000..631d3f6 --- /dev/null +++ b/internal/app/remote_control.go @@ -0,0 +1,336 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "math" + "reflect" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "github.com/godbus/dbus/v5" +) + +// The names are the Python service's public names. Each mapped field is read +// from the live snapshot and applied by the preferences worker. +var remoteFields = map[string]string{ + "SpeechManager.CapitalizationStyle": "CapitalizationStyle", + "SpeechManager.SpeakDescription": "SpeakDescription", + "SpeechManager.SpeakRowInGuiTable": "Reading.ReadFullRowInGUITable", + "TypingEchoManager.KeyEchoEnabled": "EnableKeyEcho", + "TypingEchoManager.CharacterEchoEnabled": "EnableEchoByCharacter", + "TypingEchoManager.WordEchoEnabled": "EnableEchoByWord", + "TypingEchoManager.SentenceEchoEnabled": "EnableEchoBySentence", + "TypingEchoManager.AlphabeticKeysEnabled": "EnableAlphabeticKeys", + "TypingEchoManager.NumericKeysEnabled": "EnableNumericKeys", + "TypingEchoManager.PunctuationKeysEnabled": "EnablePunctuationKeys", + "TypingEchoManager.SpaceEnabled": "EnableSpace", + "TypingEchoManager.ModifierKeysEnabled": "EnableModifierKeys", + "TypingEchoManager.FunctionKeysEnabled": "EnableFunctionKeys", + "TypingEchoManager.ActionKeysEnabled": "EnableActionKeys", + "TypingEchoManager.NavigationKeysEnabled": "EnableNavigationKeys", + "TypingEchoManager.DiacriticalKeysEnabled": "EnableDiacriticalKeys", +} + +func remoteField(settings *config.Settings, path string) reflect.Value { + value := reflect.ValueOf(settings).Elem() + for _, part := range strings.Split(path, ".") { + if value.Kind() != reflect.Struct { + return reflect.Value{} + } + value = value.FieldByName(part) + if !value.IsValid() { + return value + } + } + return value +} + +func remoteValue(settings config.Settings, module, property string) (dbus.Variant, bool) { + if module == "SpeechManager" { + switch property { + case "CurrentServer": + if !settings.EnableSpeech { + return dbus.MakeVariant(""), true + } + return dbus.MakeVariant("speechdispatcher"), true + case "AvailableServers": + return dbus.MakeVariant([]string{"speechdispatcher"}), true + case "CurrentSynthesizer": + return dbus.MakeVariant(settings.SpeechOutputModule), true + case "CurrentVoice": + if settings.DefaultVoice.Family != nil { + return dbus.MakeVariant(settings.DefaultVoice.Family.Name), true + } + return dbus.MakeVariant(""), true + case "Rate": + if settings.DefaultVoice.Rate != nil { + return dbus.MakeVariant(int32(*settings.DefaultVoice.Rate)), true + } + return dbus.MakeVariant(int32(50)), true + case "Pitch": + if settings.DefaultVoice.AveragePitch != nil { + return dbus.MakeVariant(*settings.DefaultVoice.AveragePitch), true + } + return dbus.MakeVariant(float64(5)), true + case "Volume": + if settings.DefaultVoice.Gain != nil { + return dbus.MakeVariant(*settings.DefaultVoice.Gain), true + } + return dbus.MakeVariant(float64(10)), true + case "SpeechIsEnabled": + return dbus.MakeVariant(settings.EnableSpeech), true + case "PunctuationLevel": + return dbus.MakeVariant([]string{"all", "most", "some", "none"}[settings.VerbalizePunctuationStyle]), true + } + } + path, ok := remoteFields[module+"."+property] + if !ok { + return dbus.Variant{}, false + } + field := remoteField(&settings, path) + if !field.IsValid() { + return dbus.Variant{}, false + } + return dbus.MakeVariant(field.Interface()), true +} + +type remoteSettingsChange struct { + module, property string + command string + value dbus.Variant + result chan bool + expires time.Time +} + +func (worker *preferencesWorker) RunRemoteCommand(command string) bool { + change := remoteSettingsChange{module: "SpeechManager", command: command, result: make(chan bool, 1), expires: time.Now().Add(2 * time.Second)} + select { + case worker.remoteChanges <- change: + default: + return false + } + select { + case accepted := <-change.result: + return accepted + case <-worker.done: + return false + case <-time.After(2 * time.Second): + return false + } +} + +func (worker *preferencesWorker) SetRemote(module, property string, value dbus.Variant) bool { + change := remoteSettingsChange{module: module, property: property, value: value, result: make(chan bool, 1), expires: time.Now().Add(2 * time.Second)} + select { + case worker.remoteChanges <- change: + default: + return false + } + select { + case accepted := <-change.result: + return accepted + case <-worker.done: + return false + case <-time.After(2 * time.Second): + return false + } +} + +func (worker *preferencesWorker) applyRemoteChange(ctx context.Context, change remoteSettingsChange) bool { + if time.Now().After(change.expires) { + return false + } + next := worker.settings + if change.command != "" { + if !adjustRemoteSpeech(&next, change.command) { + return false + } + } else { + if !setRemoteValue(&next, change.module, change.property, change.value) { + return false + } + } + applyCtx, cancel := context.WithDeadline(ctx, change.expires) + defer cancel() + return worker.applyNext(applyCtx, next, false, false).Accepted +} + +func adjustRemoteSpeech(settings *config.Settings, command string) bool { + var field **float64 + var defaultValue, step, upper float64 + delta := float64(1) + switch command { + case "DecreaseRate", "IncreaseRate": + field = &settings.DefaultVoice.Rate + defaultValue, step, upper = 50, 1, 99 + case "DecreasePitch", "IncreasePitch": + field = &settings.DefaultVoice.AveragePitch + defaultValue, step, upper = 5, 0.1, 9 + case "DecreaseVolume", "IncreaseVolume": + field = &settings.DefaultVoice.Gain + defaultValue, step, upper = 9, 0.1, 9 + default: + return false + } + if strings.HasPrefix(command, "Decrease") { + delta = -1 + } + value := defaultValue + if *field != nil { + value = **field + } + value = math.Round((value+delta*step)*10) / 10 + value = math.Max(0, math.Min(upper, value)) + *field = &value + return true +} + +func setRemoteValue(settings *config.Settings, module, property string, value dbus.Variant) bool { + key := module + "." + property + if module == "SpeechManager" { + switch property { + case "Rate", "Pitch", "Volume": + var number float64 + if property == "Rate" { + v, ok := value.Value().(int32) + if !ok || v < 0 || v > 99 { + return false + } + number = float64(v) + } else { + v, ok := value.Value().(float64) + if !ok { + return false + } + number = v + } + switch property { + case "Rate": + settings.DefaultVoice.Rate = &number + case "Pitch": + settings.DefaultVoice.AveragePitch = &number + case "Volume": + settings.DefaultVoice.Gain = &number + } + return true + case "CurrentSynthesizer": + v, ok := value.Value().(string) + if !ok || len(v) > 256 { + return false + } + settings.SpeechOutputModule = v + settings.DefaultVoice.Family = nil + return true + case "CurrentVoice": + v, ok := value.Value().(string) + if !ok || len(v) > 256 { + return false + } + if settings.DefaultVoice.Family == nil { + settings.DefaultVoice.Family = &config.VoiceFamily{} + } else { + family := *settings.DefaultVoice.Family + settings.DefaultVoice.Family = &family + } + settings.DefaultVoice.Family.Name = v + return true + case "PunctuationLevel": + v, ok := value.Value().(string) + if !ok { + return false + } + for i, name := range []string{"all", "most", "some", "none"} { + if strings.EqualFold(v, name) { + settings.VerbalizePunctuationStyle = i + return true + } + } + return false + } + } + path, ok := remoteFields[key] + if !ok { + return false + } + field := remoteField(settings, path) + if !field.IsValid() || !field.CanSet() { + return false + } + newValue := reflect.ValueOf(value.Value()) + if !newValue.IsValid() || newValue.Type() != field.Type() { + return false + } + field.Set(newValue) + return true +} + +func (presenter remoteSpeech) RemoteGet(module, property string) (dbus.Variant, bool) { + if presenter.live == nil { + return dbus.Variant{}, false + } + if module == "SpeechManager" && property == "SpeechIsEnabled" { + return dbus.MakeVariant(presenter.speech != nil && presenter.live.Load().EnableSpeech), true + } + if module == "SpeechManager" && presenter.speech == nil && (property == "CurrentServer" || property == "CurrentSynthesizer" || property == "CurrentVoice") { + return dbus.MakeVariant(""), true + } + return remoteValue(presenter.live.Load(), module, property) +} + +func (presenter remoteSpeech) RemoteModulesAvailable() bool { + return presenter.live != nil && presenter.preferences != nil +} + +func (presenter remoteSpeech) RemoteSet(module, property string, value dbus.Variant) bool { + return presenter.preferences != nil && presenter.preferences.SetRemote(module, property, value) +} + +var remoteSpeechCommands = map[string]input.Command{ + "SelectPreviousSpeechSetting": input.SelectPreviousSpeechSetting, + "SelectNextSpeechSetting": input.SelectNextSpeechSetting, + "DecreaseCurrentSpeechSetting": input.DecreaseSpeechSetting, + "IncreaseCurrentSpeechSetting": input.IncreaseSpeechSetting, + "SaveSpeechSettings": input.SaveSpeechSettings, +} + +func (presenter remoteSpeech) RemoteCommand(module, command string, notify bool) bool { + if module == "SpeechManager" { + if command == "InterruptSpeech" { + return presenter.CancelSpeech() + } + if strings.HasPrefix(command, "Decrease") || strings.HasPrefix(command, "Increase") { + if presenter.preferences == nil { + return false + } + accepted := presenter.preferences.RunRemoteCommand(command) + if accepted && notify { + if message, ok := map[string]string{"DecreaseRate": "Speech slower", "IncreaseRate": "Speech faster", "DecreasePitch": "Speech lower", "IncreasePitch": "Speech higher", "DecreaseVolume": "Speech softer", "IncreaseVolume": "Speech louder"}[command]; ok { + presenter.PresentMessage(message) + } + } + return accepted + } + if mapped, ok := remoteSpeechCommands[command]; ok && presenter.preferences != nil { + return presenter.preferences.TrySpeechCommand(mapped) + } + } + if module == "TypingEchoManager" && command == "CycleKeyEcho" && presenter.live != nil { + current := presenter.live.Load().EnableKeyEcho + accepted := presenter.RemoteSet(module, "KeyEchoEnabled", dbus.MakeVariant(!current)) + if accepted && notify { + if current { + presenter.PresentMessage("Key echo disabled") + } else { + presenter.PresentMessage("Key echo enabled") + } + } + return accepted + } + return false +} diff --git a/internal/app/remote_control_test.go b/internal/app/remote_control_test.go new file mode 100644 index 0000000..48013d5 --- /dev/null +++ b/internal/app/remote_control_test.go @@ -0,0 +1,70 @@ +// 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" + "path/filepath" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/speech" + "github.com/godbus/dbus/v5" +) + +type acceptingRemoteSpeechSettings struct{} + +func (acceptingRemoteSpeechSettings) UpdateSettings(context.Context, speech.Settings) error { + return nil +} + +func TestRemoteSettingsUseWorkerWithoutSaving(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + live := &liveSettings{} + live.Store(settings) + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, acceptingRemoteSpeechSettings{}) + worker.live = live + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + presenter := remoteSpeech{live: live, preferences: worker} + if !presenter.RemoteSet("TypingEchoManager", "KeyEchoEnabled", dbus.MakeVariant(true)) { + t.Fatal("key echo setting rejected") + } + value, ok := presenter.RemoteGet("TypingEchoManager", "KeyEchoEnabled") + if !ok || value.Value() != true { + t.Fatalf("live key echo = %v, %v", value, ok) + } + if !presenter.RemoteSet("SpeechManager", "PunctuationLevel", dbus.MakeVariant("none")) { + t.Fatal("punctuation setting rejected") + } + value, ok = presenter.RemoteGet("SpeechManager", "PunctuationLevel") + if !ok || value.Value() != "none" { + t.Fatalf("live punctuation = %v, %v", value, ok) + } + if presenter.RemoteSet("SpeechManager", "PunctuationLevel", dbus.MakeVariant("invalid")) { + t.Fatal("invalid punctuation accepted") + } + if presenter.RemoteSet("TypingEchoManager", "KeyEchoEnabled", dbus.MakeVariant("wrong type")) { + t.Fatal("wrong type accepted") + } + if !presenter.RemoteCommand("SpeechManager", "IncreaseRate", false) { + t.Fatal("rate command rejected") + } + value, ok = presenter.RemoteGet("SpeechManager", "Rate") + if !ok || value.Value() != int32(51) { + t.Fatalf("rate after command = %v, %v", value, ok) + } + if presenter.RemoteCommand("SpeechManager", "Unknown", false) { + t.Fatal("unknown command accepted") + } + if _, err := os.Stat(settings.Path); !os.IsNotExist(err) { + t.Fatalf("remote setter wrote settings file: %v", err) + } +} diff --git a/internal/app/remote_test.go b/internal/app/remote_test.go new file mode 100644 index 0000000..3cca6dd --- /dev/null +++ b/internal/app/remote_test.go @@ -0,0 +1,140 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import "testing" + +type remoteSpeechQueue struct { + requests []speechRequest + interrupts int +} + +type remoteBrailleQueue struct { + running bool + text []string +} + +func (queue *remoteBrailleQueue) EnqueueMessage(text string) bool { + if !queue.running { + return false + } + queue.text = append(queue.text, text) + return true +} + +func TestRemoteBrailleMessage(t *testing.T) { + queue := &remoteBrailleQueue{} + presenter := remoteSpeech{braille: queue} + if presenter.BrailleMessage("Unavailable") || len(queue.text) != 0 { + t.Fatal("message accepted while braille is stopped") + } + queue.running = true + if !presenter.BrailleMessage("Braille") || len(queue.text) != 1 || queue.text[0] != "Braille" { + t.Fatalf("braille message was not queued: %v", queue.text) + } + if presenter.BrailleMessage("") { + t.Fatal("empty message accepted") + } +} + +func TestRemotePresentMessageUsesSpeechAndBraille(t *testing.T) { + speech := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + presenter := remoteSpeech{speech: speech, braille: braille} + if !presenter.PresentMessage("Status") || len(speech.requests) != 1 || + len(braille.text) != 1 || braille.text[0] != "Status" { + t.Fatalf("present message: speech=%v braille=%v", speech.requests, braille.text) + } + if !(remoteSpeech{braille: braille}).PresentMessage("Braille only") { + t.Fatal("braille-only presentation rejected") + } +} + +func TestWineMessageKeepsSourcePrivateInSpeechRequest(t *testing.T) { + speech := &remoteSpeechQueue{} + presenter := remoteSpeech{speech: speech} + if !presenter.presentMessage("Game control", "wine-accessible-event") { + t.Fatal("Wine presentation rejected") + } + if len(speech.requests) != 1 || speech.requests[0].Cause != "wine-accessible-event" { + t.Fatalf("speech requests = %+v", speech.requests) + } +} + +func (queue *remoteSpeechQueue) Enqueue(request speechRequest) { + queue.requests = append(queue.requests, request) +} + +func (queue *remoteSpeechQueue) Interrupt() { + queue.interrupts++ +} + +func TestRemoteSpeechQueuesWithoutInterruptingByDefault(t *testing.T) { + queue := &remoteSpeechQueue{} + presenter := remoteSpeech{speech: queue} + if !presenter.PresentMessage("Status") { + t.Fatal("PresentMessage rejected available speech") + } + if !presenter.SpeakText("Queued", false) { + t.Fatal("SpeakText rejected available speech") + } + if queue.interrupts != 0 { + t.Fatalf("interrupts = %d, want 0", queue.interrupts) + } + if len(queue.requests) != 2 { + t.Fatalf("requests = %d, want 2", len(queue.requests)) + } + for index, request := range queue.requests { + if !request.Append { + t.Fatalf("request %d was not queued as append speech", index) + } + } +} + +func TestRemoteSpeechInterruptsBeforeRequestedSpeech(t *testing.T) { + queue := &remoteSpeechQueue{} + presenter := remoteSpeech{speech: queue} + if !presenter.SpeakText("Now", true) { + t.Fatal("SpeakText rejected available speech") + } + if queue.interrupts != 1 { + t.Fatalf("interrupts = %d, want 1", queue.interrupts) + } + if len(queue.requests) != 1 || queue.requests[0].Text != "Now" || !queue.requests[0].Append { + t.Fatalf("request = %+v, want queued Now speech", queue.requests) + } +} + +func TestRemoteSpeechCancellationAndUnavailableBraille(t *testing.T) { + queue := &remoteSpeechQueue{} + presenter := remoteSpeech{speech: queue} + if !presenter.CancelSpeech() { + t.Fatal("CancelSpeech rejected available speech") + } + if queue.interrupts != 1 { + t.Fatalf("interrupts = %d, want 1", queue.interrupts) + } + if presenter.BrailleMessage("Braille") { + t.Fatal("BrailleMessage succeeded without a braille backend") + } + + unavailable := remoteSpeech{} + if unavailable.PresentMessage("Status") || unavailable.SpeakText("Text", false) || unavailable.CancelSpeech() { + t.Fatal("remote speech succeeded without a speech worker") + } +} diff --git a/internal/app/review_spelling.go b/internal/app/review_spelling.go new file mode 100644 index 0000000..09c1a2a --- /dev/null +++ b/internal/app/review_spelling.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "unicode" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// Shared by review spelling and learn mode, matching Python Cthulhu's names. +func phoneticLetter(letter rune) string { + words := [...]string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu"} + letter = unicode.ToLower(letter) + if letter < 'a' || letter > 'z' { + return "" + } + return words[letter-'a'] +} + +func flat_review_speech(command input.Command) review.FlatSpeech { + switch command { + case input.ReviewSpellWord: + return review.FlatSpell + case input.ReviewPhoneticWord, input.ReviewPhoneticCharacter: + return review.FlatPhonetic + } + return review.FlatSpeak +} + +// Preserve blank/whitespace announcements. Otherwise spell Unicode runes as +// separate utterances, retaining repeated letters and character-mode speech. +func set_review_spelling(request *speechRequest, text string, style review.FlatSpeech) { + if style == review.FlatSpeak || strings.TrimSpace(text) == "" { + return + } + request.Character = style == review.FlatSpell + for _, letter := range text { + kind := "" + part := presentation.FlatReviewText(string(letter), true) + if style == review.FlatPhonetic { + if unicode.IsUpper(letter) { + kind = "uppercase" + } + if name := phoneticLetter(letter); name != "" { + part = name + } else if unicode.IsLetter(letter) { + part = string(unicode.ToLower(letter)) + } + } + request.Spelling = append(request.Spelling, part) + request.SpellingVoices = append(request.SpellingVoices, kind) + } +} diff --git a/internal/app/review_spelling_test.go b/internal/app/review_spelling_test.go new file mode 100644 index 0000000..9acf664 --- /dev/null +++ b/internal/app/review_spelling_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "log/slog" + "reflect" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestFlatReviewSpellingPresentation(t *testing.T) { + for _, tc := range []struct { + command input.Command + text string + parts []string + character bool + }{ + {input.ReviewCurrentWord, "book", nil, false}, + {input.ReviewCurrentCharacter, "b", nil, true}, + {input.ReviewPhoneticCharacter, "B", []string{"bravo"}, false}, + {input.ReviewSpellWord, "Book", []string{"B", "o", "o", "k"}, true}, + {input.ReviewPhoneticWord, "Book", []string{"bravo", "oscar", "oscar", "kilo"}, false}, + {input.ReviewSpellWord, "猫1!", []string{"猫", "1", "!"}, true}, + {input.ReviewPhoneticWord, "猫1!", []string{"猫", "1", "!"}, false}, + {input.ReviewPhoneticCharacter, " ", nil, true}, + {input.ReviewPhoneticWord, "", nil, false}, + } { + queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)} + unit, step := flatReviewCommand(tc.command) + result := review.Result{FlatUnit: unit, FlatStep: step, FlatSpeech: flat_review_speech(tc.command), FlatPosition: review.FlatPosition{Text: tc.text}} + presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, result) + request := <-queue.requests + if !reflect.DeepEqual(request.Spelling, tc.parts) || request.Character != tc.character { + t.Fatalf("%s %q request=%#v", tc.command, tc.text, request) + } + } +} + +type spellingTestSpeaker struct { + parts []string + characters []string + after func() +} + +func (*spellingTestSpeaker) Connect(context.Context) error { return nil } +func (*spellingTestSpeaker) Close(context.Context) error { return nil } +func (*spellingTestSpeaker) Cancel(context.Context) error { return nil } +func (speaker *spellingTestSpeaker) Speak(_ context.Context, text string) error { + speaker.parts = append(speaker.parts, text) + if speaker.after != nil { + speaker.after() + } + return nil +} +func (speaker *spellingTestSpeaker) SpeakCharacter(ctx context.Context, text string) error { + speaker.characters = append(speaker.characters, text) + return speaker.Speak(ctx, text) +} + +func TestReviewSpellingDeliveryAndInterruption(t *testing.T) { + for _, style := range []review.FlatSpeech{review.FlatSpell, review.FlatPhonetic} { + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + request := speechRequest{Text: "book", Cause: "flat-review"} + set_review_spelling(&request, strings.Repeat("book", 20), style) + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + if len(speaker.parts) != 80 { + t.Fatalf("long spelling lost letters: %d", len(speaker.parts)) + } + wantCharacters := 0 + if style == review.FlatSpell { + wantCharacters = 80 + } + if len(speaker.characters) != wantCharacters { + t.Fatalf("character-mode delivery=%d", len(speaker.characters)) + } + worker.running = true // Exercise the real interruption generation barrier. + speaker.parts = nil + speaker.after = worker.InterruptForUser + if err := worker.deliver(context.Background(), request); !errors.Is(err, context.Canceled) { + t.Fatalf("interrupt=%v", err) + } + if len(speaker.parts) != 1 { + t.Fatalf("spelling continued after input: %v", speaker.parts) + } + } +} diff --git a/internal/app/runtime_backends.go b/internal/app/runtime_backends.go new file mode 100644 index 0000000..e56fc5b --- /dev/null +++ b/internal/app/runtime_backends.go @@ -0,0 +1,131 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// runtimeBackends carries constructed backends only between assembly and Run. +// The lifecycle runner owns their start/stop order after construction. +type runtimeBackends struct { + speechOutput speechEnqueuer + brailleOutput *brailleWorker + speechMessages *speechWorker + history *speechHistory + settingsUpdater any + inputListener InputListener + copyChanges <-chan struct{} + sounds soundService + services []lifecycle.Service +} + +// newRuntimeBackends registers sound, speech, speech worker, input, and the +// optional clipboard monitor in that order. The speech worker has a lifetime +// independent of the event-loop context so farewell can be delivered. +func newRuntimeBackends(options *Options, settings config.Settings, logger *slog.Logger, adapter Adapter) (*runtimeBackends, error) { + var speechOutput speechEnqueuer = discardSpeech{} + brailleOutput, err := newBrailleWorker(logger) + if err != nil { + return nil, err + } + brailleOutput.router, _ = adapter.(brailleRouter) + brailleOutput.SetContraction(settings.EnableContractedBraille, settings.BrailleContractionTable) + brailleOutput.SetWordWrap(settings.Braille.WordWrap) + backends := &runtimeBackends{ + speechOutput: speechOutput, + brailleOutput: brailleOutput, + services: make([]lifecycle.Service, 0, 5), + } + if options.newSound != nil { + backends.sounds = options.newSound() + } else { + backends.sounds = sound.New(logger, sound.NativePlayer{Sink: settings.SoundSink}, settings.SoundTheme, settings.SoundVolume) + } + if updater, ok := backends.sounds.(soundSettingsUpdater); ok { + updater.Configure(soundConfiguration(settings)) + } + backends.services = append(backends.services, lifecycle.Service{Name: "sound", Start: backends.sounds.Start, Stop: func(context.Context) error { + soundContext, cancelSound := context.WithTimeout(context.Background(), soundStopTimeout) + defer cancelSound() + logger.Debug("playing shutdown sound") + if err := backends.sounds.Stop(soundContext); err != nil { + logger.Warn("stop sound service", "error", err) + return err + } + logger.Debug("shutdown sound completed") + return nil + }}) + if options.newSpeaker == nil { + options.newSpeaker = func() Speaker { + return newConfiguredSpeaker(options.SpeechSocket, settings) + } + } + speaker := options.newSpeaker() + backends.settingsUpdater = speaker + backends.speechMessages = newSpeechWorker(logger, speaker) + backends.speechMessages.SetEnabled(settings.EnableSpeech) + if beeper, ok := backends.sounds.(interface{ SpeechBeep(context.Context) error }); ok { + backends.speechMessages.beep = beeper.SpeechBeep + } + backends.history = newSpeechHistory(settings.SpeechHistoryLimit) + backends.speechMessages.history = backends.history + backends.speechOutput = backends.speechMessages + if options.newInput == nil { + options.newInput = func() InputListener { return xorg.New() } + } + backends.inputListener = options.newInput() + backends.services = append(backends.services, + lifecycle.Service{Name: "speech", Start: func(ctx context.Context) error { + if !settings.EnableSpeech { + return nil + } + return speaker.Connect(ctx) + }, Stop: speaker.Close}, + lifecycle.Service{Name: "speech worker", Start: func(context.Context) error { + // The runner owns this worker's lifetime. Keeping it independent of + // the event-loop context lets graceful shutdown queue its farewell. + return backends.speechMessages.Start(context.Background()) + }, Stop: backends.speechMessages.Stop}, + ) + backends.services = append(backends.services, + desktopInputService("X11 listener", backends.inputListener, logger), + ) + if _, native := backends.inputListener.(*xorg.Listener); native { + clipboardMonitor := xorg.NewClipboardMonitor() + backends.services = append(backends.services, lifecycle.Service{ + Name: "clipboard changes", + Start: func(ctx context.Context) error { + if err := clipboardMonitor.Start(ctx); err != nil { + logger.Warn("copy feedback unavailable", "error", err) + return nil + } + backends.copyChanges = clipboardMonitor.Changes() + return nil + }, + Stop: clipboardMonitor.Stop, + }) + } + return backends, nil +} diff --git a/internal/app/runtime_commands.go b/internal/app/runtime_commands.go new file mode 100644 index 0000000..e22145b --- /dev/null +++ b/internal/app/runtime_commands.go @@ -0,0 +1,280 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "log/slog" + "os" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// commandRuntime owns command dispatch while the event loop owns accessibility state. +type commandRuntime struct { + internalUI *internalUI + actionSource accessibleActionSource + tentacles *tentacleRuntime + logger *slog.Logger + commands commandListener + focusChanges <-chan xorg.FocusState + focusGate *focusGate + speechMessages *speechWorker + speechOutput speechEnqueuer + learn *learnModePresenter + browser *browserModeController + preferences *preferencesWorker + onPreferences func() + onSpeechHistory func() + onSpeechMonitor func() + onNotificationList func() + live *liveSettings + clipboardRequests chan<- struct{} + reviewRequests chan<- struct{} + attributeRequests chan<- struct{} + objectReview *review.Worker + ocr *ocrSession + notifications *notification.Presenter + stopRun context.CancelFunc +} + +func (worker commandRuntime) run(ctx context.Context) error { + logger := worker.logger + commands := worker.commands + focusChanges := worker.focusChanges + focusGate := worker.focusGate + speechMessages := worker.speechMessages + speechOutput := worker.speechOutput + learn := worker.learn + browser := worker.browser + preferences := worker.preferences + onPreferences := worker.onPreferences + onSpeechHistory := worker.onSpeechHistory + onSpeechMonitor := worker.onSpeechMonitor + onNotificationList := worker.onNotificationList + live := worker.live + clipboardRequests := worker.clipboardRequests + reviewRequests := worker.reviewRequests + objectReview := worker.objectReview + notifications := worker.notifications + stopRun := worker.stopRun + var tentacleAnnouncements <-chan string + var tentacleFailures <-chan error + if worker.tentacles != nil { + tentacleFailures = worker.tentacles.failures + tentacleAnnouncements = worker.tentacles.announcements + } + var ocrFailures <-chan error + if worker.ocr != nil { + ocrFailures = worker.ocr.failures + } + for { + select { + case <-ctx.Done(): + return nil + case message := <-tentacleAnnouncements: + if !focusGate.Locked() && !focusGate.Suspended() { + learn.present(message, "tentacle-status") + } + case err := <-tentacleFailures: + stopRun() + return err + case err := <-ocrFailures: + stopRun() + return err + case err := <-commands.Failures(): + stopRun() + return err + case state := <-focusChanges: + if !state.Known { + continue + } + if focusGate.SetSuspended(state.XTerm) { + if state.XTerm { + logger.Info("suspended for XTerm focus handoff") + } else { + logger.Info("resumed after XTerm focus handoff") + } + } + case state := <-commands.LockStates(): + if focusGate.Locked() { + continue + } + if speechMessages != nil { + speechMessages.ProtectCommand(state.Timestamp) + } + focusGate.dismiss_confirmation() + speechOutput.Enqueue(speechRequest{Text: state.Text(), Role: "system", Cause: "lock-state"}) + logger.Debug("keyboard lock state changed", "key", state.Name, "locked", state.Locked) + case event := <-commands.Commands(): + focusGate.retireTentacleOutput() + if focusGate.Locked() { + continue + } + command := event.Command + if speechMessages != nil { + speechMessages.ProtectCommand(event.Timestamp) + } + focusGate.dismiss_confirmation() + logger.Debug("keyboard command received", "command", command, "input_timestamp", event.Timestamp) + if worker.internalUI.Handle(event) { + continue + } + if worker.internalUI != nil && (command == input.ShowActions || command == input.InternalUIKey || command == input.InternalUIDismiss) { + stopRun() + return errors.New("internal UI event queue overflow") + } + if worker.ocr.Handle(event) { + continue + } + switch command { + case input.ToggleLearnMode, input.LearnKey, input.LearnDocumentation, input.LearnDefaultShortcuts, input.LearnApplicationShortcuts: + learn.handle(event) + continue + } + if !focusGate.Suspended() && worker.tentacles.Invoke(ctx, command) { + continue + } + if browser != nil { + browser.observeCommand(command) + } + switch command { + case input.DisableTentacles: + worker.tentacles.Disable() + learn.present("Tentacles disabled.", "tentacles-disabled") + case input.CycleSpeechMode: + if speechMessages != nil { + mode := speechMessages.cycle_speech_mode() + speechOutput.Enqueue(speechRequest{Text: mode.announcement(), Role: "system", Cause: "speech-mode", ForceSpeech: true}) + } + case input.Quit: + logger.Info("quit command received") + stopRun() + return nil + case input.ToggleDocumentMode, input.StickyFocusMode, input.StickyBrowseMode: + if browser != nil { + select { + case browser.toggles <- command: + default: + } + } + case input.SelectPreviousSpeechSetting, input.SelectNextSpeechSetting, input.DecreaseSpeechSetting, input.IncreaseSpeechSetting, input.SaveSpeechSettings, input.CycleProgressBarOutput: + logger.Debug("speech setting command received", "command", command) + preferences.SpeechCommand(command) + case input.Preferences: + logger.Info("preferences command received") + if onPreferences != nil { + onPreferences() + } + case input.ApplicationPreferences: + logger.Info("application preferences command received") + if preferences != nil { + preferences.OpenApplication() + } + case input.ChooseApplicationPreferences: + if preferences != nil { + preferences.ChooseApplication() + } + case input.PresentTime, input.PresentDate: + presentDateTime(speechOutput, live.Load(), command, time.Now()) + case input.PresentClipboard: + select { + case clipboardRequests <- struct{}{}: + default: + } + case input.ShowSpeechHistory: + if onSpeechHistory != nil { + onSpeechHistory() + } + case input.ToggleSpeechMonitor: + if onSpeechMonitor != nil { + onSpeechMonitor() + } + case input.PresentVersion: + presentVersion( + speechOutput, + atspi.Version(), + os.Getenv("XDG_SESSION_TYPE"), + os.Getenv("XDG_SESSION_DESKTOP"), + ) + case input.PresentLastNotification: + presentNotifications(speechOutput, notifications.PresentLast()) + case input.PresentPreviousNotification: + presentNotifications(speechOutput, notifications.PresentPrevious()) + case input.PresentNextNotification: + presentNotifications(speechOutput, notifications.PresentNext()) + case input.ShowNotificationList: + if onNotificationList != nil { + onNotificationList() + } + case input.WhereAmI: + logger.Info("where am I command received") + select { + case reviewRequests <- struct{}{}: + default: + } + case input.PresentCharacterAttributes: + select { + case worker.attributeRequests <- struct{}{}: + default: + } + case input.ObjectParent: + queueObjectReviewMove(logger, objectReview, review.Parent) + case input.ObjectFirstChild: + queueObjectReviewMove(logger, objectReview, review.FirstChild) + case input.ObjectPreviousSibling: + queueObjectReviewMove(logger, objectReview, review.PreviousSibling) + case input.ObjectNextSibling: + queueObjectReviewMove(logger, objectReview, review.NextSibling) + case input.DocumentActivate: + if objectReview != nil { + if err := objectReview.ActivateDocumentCaret(); err != nil { + logger.Warn("queue document activation", "error", err) + } + } + case input.ReadAll: + if objectReview != nil && speechMessages != nil { + if err := objectReview.ReadAll(speechMessages.BeginReadAll(ctx, event.Timestamp)); err != nil { + logger.Warn("queue read all", "error", err) + } + } + default: + if target, level, backwards := documentReviewCommand(command); target != 0 { + if err := objectReview.MoveDocument(target, level, backwards); err != nil { + logger.Warn("queue document navigation", "error", err) + } + } else if caretCommand := documentCaretCommand(command); caretCommand != 0 { + if err := objectReview.MoveDocumentCaret(caretCommand); err != nil { + logger.Warn("queue document caret navigation", "error", err) + } + } else if unit, step := flatReviewCommand(command); unit != 0 { + logger.Info("flat review command received", "command", command) + if err := objectReview.ReadFlatWithSpeech(unit, step, flat_review_speech(command)); err != nil { + logger.Warn("queue flat review", "error", err) + } + } + } + } + } +} diff --git a/internal/app/runtime_commands_test.go b/internal/app/runtime_commands_test.go new file mode 100644 index 0000000..4489ed9 --- /dev/null +++ b/internal/app/runtime_commands_test.go @@ -0,0 +1,317 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestRunRoutesPreferencesWithoutStoppingCommandHandling(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + settings.EnableSound = false + adapter := &fakeAdapter{events: make(chan atspi.Event)} + commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)} + preferencesCalled := make(chan struct{}, 1) + completed := make(chan error, 1) + + go func() { + completed <- Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), + Settings: &settings, + newCommands: func() commandListener { return commands }, + onPreferences: func() { preferencesCalled <- struct{}{} }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + + commands.commands <- input.CommandEvent{Command: input.Preferences} + select { + case <-preferencesCalled: + case <-time.After(time.Second): + t.Fatal("preferences callback was not invoked") + } + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-completed: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("quit was not handled after preferences") + } +} + +func TestRunRoutesShowNotificationList(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + settings.EnableSound = false + adapter := &fakeAdapter{events: make(chan atspi.Event)} + commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)} + listCalled := make(chan struct{}, 1) + completed := make(chan error, 1) + + go func() { + completed <- Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), Settings: &settings, + newCommands: func() commandListener { return commands }, + onNotificationList: func() { listCalled <- struct{}{} }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + + commands.commands <- input.CommandEvent{Command: input.ShowNotificationList} + select { + case <-listCalled: + case <-time.After(time.Second): + t.Fatal("notification list callback was not invoked") + } + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-completed: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("quit was not handled after opening the notification list") + } +} + +func TestRunRoutesNotificationCommands(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = true + settings.EnableSound = false + speaker := &fakeSpeaker{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)} + listCalled := make(chan struct{}, 1) + completed := make(chan error, 1) + + go func() { + completed <- Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), Settings: &settings, + newCommands: func() commandListener { return commands }, + newSpeaker: func() Speaker { return speaker }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { + return &fakeInputListener{ + events: make(chan input.Event), + modifierKeycodes: input.NewModifierKeycodes(37), + } + }, + onNotificationList: func() { + select { + case listCalled <- struct{}{}: + default: + } + }, + }) + }() + + select { + case message := <-speaker.spoken: + if !contains_string(startupMessages, message) { + t.Fatalf("startup speech = %q, want one of %q", message, startupMessages) + } + case <-time.After(time.Second): + t.Fatal("startup speech was not presented") + } + + commands.commands <- input.CommandEvent{Command: input.PresentLastNotification} + select { + case message := <-speaker.spoken: + if message != "No notification messages" { + t.Fatalf("last notification speech = %q, want %q", message, "No notification messages") + } + case <-time.After(time.Second): + t.Fatal("last notification speech was not presented") + } + + commands.commands <- input.CommandEvent{Command: input.ShowNotificationList} + select { + case <-listCalled: + case <-time.After(time.Second): + t.Fatal("notification list callback was not invoked") + } + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-completed: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("quit was not handled after notification commands") + } +} + +func TestRunCommandLoopRemainsResponsiveDuringObjectCapture(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = true + settings.EnableSound = false + speaker := &fakeSpeaker{spoken: make(chan string, 3)} + inputListener := &fakeInputListener{ + events: make(chan input.Event), + modifierKeycodes: input.NewModifierKeycodes(37), + } + captureStarted := make(chan struct{}) + adapter := &fakeAdapter{ + events: make(chan atspi.Event, 1), + capture: func(ctx context.Context) (*atspi.AccessibleTree, error) { + close(captureStarted) + <-ctx.Done() + return nil, ctx.Err() + }, + } + commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)} + completed := make(chan error, 1) + + go func() { + completed <- Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), + Settings: &settings, + newSpeaker: func() Speaker { return speaker }, + newInput: func() InputListener { return inputListener }, + newCommands: func() commandListener { return commands }, + onPreferences: func() {}, + newAdapter: func() Adapter { return adapter }, + }) + }() + select { + case <-speaker.spoken: + case <-time.After(time.Second): + t.Fatal("startup speech was not delivered") + } + focus := atspi.NewSnapshot("/focused", "Focused", "push button", "", "") + focus.BusName = ":1.42" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, focus) + for _, want := range []string{"Focused", "push button"} { + select { + case text := <-speaker.spoken: + if text != want { + t.Fatalf("focus speech = %q, want %q", text, want) + } + case <-time.After(time.Second): + t.Fatal("focus was not delivered before object review command") + } + } + commands.commands <- input.CommandEvent{Command: input.ObjectNextSibling} + select { + case <-captureStarted: + case <-time.After(time.Second): + t.Fatal("object review capture did not start") + } + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-completed: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("quit command was blocked by object review capture") + } + if !adapter.stopped { + t.Fatal("adapter did not stop after capture cancellation") + } +} + +type attributeFakeAdapter struct { + *fakeAdapter + values map[string]string +} + +func (adapter *attributeFakeAdapter) ReadCharacterAttributes(_ context.Context, object atspi.ObjectID) (atspi.CharacterAttributes, error) { + return atspi.CharacterAttributes{Object: object, Values: adapter.values}, nil +} + +func TestRunPresentsCurrentCharacterColorNames(t *testing.T) { + for _, probe := range []struct { + names bool + want string + }{{true, "foreground color: red"}, {false, "foreground color: 255 0 0"}} { + t.Run(probe.want, func(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSound = false + settings.EnableBraille = false + settings.Reading.UseColorNames = probe.names + speaker := &fakeSpeaker{spoken: make(chan string, 4)} + adapter := &attributeFakeAdapter{ + fakeAdapter: &fakeAdapter{events: make(chan atspi.Event, 1)}, + values: map[string]string{"fg-color": "rgb(65535,0,0)"}, + } + commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan error, 1) + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), Settings: &settings, + newSpeaker: func() Speaker { return speaker }, + newAdapter: func() Adapter { return adapter }, + newCommands: func() commandListener { return commands }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + defer func() { + cancel() + select { + case err := <-completed: + if err != nil { + t.Error(err) + } + case <-time.After(2 * time.Second): + t.Error("reader did not stop") + } + }() + select { + case <-speaker.spoken: + case <-time.After(time.Second): + t.Fatal("startup speech missing") + } + focus := atspi.NewSnapshot("/entry", "Color entry", "entry", "", "") + focus.BusName = ":1.42" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, focus) + for _, want := range []string{"Color entry", "entry"} { + select { + case got := <-speaker.spoken: + if got != want { + t.Fatalf("focus speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("focus speech missing") + } + } + commands.commands <- input.CommandEvent{Command: input.PresentCharacterAttributes} + select { + case spoken := <-speaker.spoken: + if spoken != probe.want { + t.Fatalf("character attributes = %q, want %q", spoken, probe.want) + } + case <-time.After(time.Second): + t.Fatal("character attribute command did not speak") + } + }) + } +} diff --git a/internal/app/runtime_notifications.go b/internal/app/runtime_notifications.go new file mode 100644 index 0000000..8b4bcb9 --- /dev/null +++ b/internal/app/runtime_notifications.go @@ -0,0 +1,57 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/sessionbus" +) + +// newNotificationRuntime constructs notification services in their original +// registration order. The caller appends them before speech history and remote +// services, preserving reverse-order shutdown. +func newNotificationRuntime(logger *slog.Logger, speech speechEnqueuer, braille *brailleWorker, live *liveSettings, onList func()) (*notification.Presenter, func(), []lifecycle.Service) { + presenter := notification.NewPresenter() + announcer := ¬ificationAnnouncer{presenter: presenter, speech: speech, braille: braille, live: live} + var services []lifecycle.Service + if makoConnection, err := sessionbus.Connect(logger, "notifications"); err == nil { + makoMonitor := notification.NewMakoMonitor(makoConnection, presenter, announcer) + presenter.SetMakoController(makoMonitor) + services = append(services, lifecycle.Service{ + Name: "mako-notifications", + Start: func(ctx context.Context) error { + return makoMonitor.Start(ctx) + }, + Stop: func(ctx context.Context) error { + return makoMonitor.Stop(ctx) + }, + }) + } + if onList == nil { + list := newNotificationListWorker(logger, presenter, speech) + onList = list.Open + services = append(services, lifecycle.Service{ + Name: "notification list", Start: list.Start, Stop: list.Stop, + }) + } + return presenter, onList, services +} diff --git a/internal/app/runtime_platform.go b/internal/app/runtime_platform.go new file mode 100644 index 0000000..9fd0e10 --- /dev/null +++ b/internal/app/runtime_platform.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" + "errors" + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/platform/session" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +type desktopInputBackend interface { + Start(context.Context) error + Stop(context.Context) error +} + +// X11 services remain usable through Xwayland when available. Their startup +// failure must not prevent AT-SPI, speech and braille from running on Wayland. +// Other backends retain their own startup contract. The lifecycle runner still +// owns cleanup, including a native listener whose startup failed. +func desktopInputService(name string, backend desktopInputBackend, logger *slog.Logger) lifecycle.Service { + service := lifecycle.Service{Name: name, Start: backend.Start, Stop: backend.Stop} + switch backend.(type) { + case *xorg.Listener, *xorg.CommandListener: + if session.Wayland() { + service.Start = func(ctx context.Context) error { + return startOptionalXorg(ctx, name, backend.Start, logger) + } + } + } + return service +} + +func startOptionalXorg(ctx context.Context, name string, start func(context.Context) error, logger *slog.Logger) error { + err := start(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + if err != nil { + logger.Warn("X11 input service unavailable on Wayland; continuing without it", "service", name, "error", err) + } + return nil +} diff --git a/internal/app/runtime_platform_test.go b/internal/app/runtime_platform_test.go new file mode 100644 index 0000000..b3783df --- /dev/null +++ b/internal/app/runtime_platform_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +func TestDesktopInputAvailability(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + for _, kind := range []string{"x11", "wayland"} { + t.Run(kind, func(t *testing.T) { + t.Setenv("XDG_SESSION_TYPE", kind) + t.Setenv("DISPLAY", "") + backends := []desktopInputBackend{xorg.New(), xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)} + for _, backend := range backends { + service := desktopInputService("test", backend, logger) + err := service.Start(context.Background()) + if (err == nil) != (kind == "wayland") { + t.Fatalf("%T startup in %s: %v", backend, kind, err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + if err := service.Stop(ctx); err != nil { + t.Errorf("%T cleanup: %v", backend, err) + } + cancel() + } + // Future non-Xorg backends must keep their own failure contract. + failure := errors.New("other backend failed") + other := &fakeInputListener{startError: failure} + if err := desktopInputService("other", other, logger).Start(context.Background()); !errors.Is(err, failure) { + t.Fatalf("other backend startup: %v", err) + } + }) + } +} + +func TestOptionalXorgPreservesCancellation(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + for _, failure := range []error{context.Canceled, context.DeadlineExceeded} { + if err := startOptionalXorg(context.Background(), "test", func(context.Context) error { return failure }, logger); !errors.Is(err, failure) { + t.Fatalf("startup: %v, want %v", err, failure) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := startOptionalXorg(ctx, "test", func(context.Context) error { return nil }, logger); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled startup: %v", err) + } +} + +func TestRunWaylandWithoutX11SpeaksAndStops(t *testing.T) { + t.Setenv("XDG_SESSION_TYPE", "wayland") + t.Setenv("DISPLAY", "") + speaker := &fakeSpeaker{spoken: make(chan string, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), + newSpeaker: func() Speaker { return speaker }, + newSound: func() soundService { return silentSound{} }, + newAdapter: func() Adapter { return adapter }, + }) + }() + adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", "")) + for _, expected := range []string{"", "Save", "push button"} { + select { + case spoken := <-speaker.spoken: + if expected == "" && contains_string(startupMessages, spoken) || expected != "" && spoken == expected { + continue + } + t.Fatalf("speech = %q, expected %q", spoken, expected) + case err := <-completed: + t.Fatalf("runtime exited before focus speech: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("runtime did not present focus with X11 unavailable") + } + } + cancel() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("runtime did not stop") + } + if !adapter.stopped || !speaker.closed { + t.Fatal("runtime did not clean up AT-SPI and speech") + } +} diff --git a/internal/app/runtime_remote.go b/internal/app/runtime_remote.go new file mode 100644 index 0000000..a482c58 --- /dev/null +++ b/internal/app/runtime_remote.go @@ -0,0 +1,130 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "strconv" + "time" + + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/nvda2cthulhu" + "git.stormux.org/storm/cthulhu/internal/remote" + "git.stormux.org/storm/cthulhu/internal/wine" +) + +// remoteServiceDependencies are held by the service closures for the lifetime +// of the runner. The input and adapter counters are read only on snapshot calls. +type remoteServiceDependencies struct { + logger *slog.Logger + stopRun context.CancelFunc + adapter Adapter + inputListener InputListener + speech *speechWorker + braille *brailleWorker + live *liveSettings + preferences *preferencesWorker + onPreferences func() +} + +func newRemoteRuntimeServices(deps remoteServiceDependencies) []lifecycle.Service { + remoteHandler := remoteSpeech{braille: lockFeedbackBraille{deps.braille}} + remoteHandler.live = deps.live + remoteHandler.preferences = deps.preferences + remoteHandler.showPreferences = deps.onPreferences + remoteHandler.quit = func() { time.AfterFunc(100*time.Millisecond, deps.stopRun) } + remoteHandler.runtimeSnapshot = func() string { + snapshot := "CTHULHU RUNTIME STATE SNAPSHOT\n[event_manager]\noverflow: " + strconv.FormatUint(deps.adapter.Overflow(), 10) + if deps.inputListener != nil { + snapshot += "\n[input_event_manager]\noverflow: " + strconv.FormatUint(deps.inputListener.Overflow(), 10) + } + return snapshot + } + if deps.speech != nil { + remoteHandler.speech = deps.speech + } + wineEvents := newWineEventPresenter(func(message string) bool { + return remoteHandler.presentMessage(message, "wine-accessible-event") + }) + wineEvents.logger = deps.logger + connectWineWindowTitles(wineEvents, deps.live, deps.adapter) + remoteHandler.wine = wineEvents + if deps.live != nil { + remoteHandler.wineReview = deps.live.wineReview + } + remoteService := remote.New(deps.logger, remoteHandler) + services := []lifecycle.Service{{ + Name: "self-voice D-Bus", + Start: func(ctx context.Context) error { + if err := remoteService.Start(ctx); err != nil { + deps.logger.Warn("Cthulhu D-Bus service unavailable", "error", err) + } + return nil + }, + Stop: func(ctx context.Context) error { + wineEvents.Close() + if err := remoteService.Stop(ctx); err != nil { + deps.logger.Warn("Cthulhu D-Bus service shutdown", "error", err) + } + return nil + }, + }} + if remoteHandler.wineReview != nil { + registry := remoteHandler.wineReview + // Reverse shutdown cancels captures before the borrowed service bus closes. + services = append(services, lifecycle.Service{Name: "Wine flat review", Start: func(ctx context.Context) error { + if err := registry.Start(ctx); err != nil { + deps.logger.Warn("Wine flat review unavailable", "error", err) + } + return nil + }, Stop: registry.Stop}) + } + orcaService := remote.NewOrca(deps.logger, remoteHandler) + services = append(services, lifecycle.Service{ + Name: "Orca compatibility D-Bus", + Start: func(ctx context.Context) error { + if err := orcaService.Start(ctx); err != nil { + deps.logger.Warn("Orca compatibility D-Bus service unavailable", "error", err) + } + return nil + }, + Stop: func(ctx context.Context) error { + if err := orcaService.Stop(ctx); err != nil { + deps.logger.Warn("Orca compatibility D-Bus service shutdown", "error", err) + } + return nil + }, + }) + bridgeServer := nvda2cthulhu.New(deps.logger, remoteHandler) + services = append(services, lifecycle.Service{ + Name: "NVDA to Cthulhu bridge", + Start: func(ctx context.Context) error { + if err := bridgeServer.Start(ctx); err != nil { + deps.logger.Warn("NVDA to Cthulhu bridge unavailable", "error", err) + } + return nil + }, + Stop: bridgeServer.Stop, + }) + wineManager := wine.New(deps.logger) + services = append(services, lifecycle.Service{ + Name: "Wine accessibility", Start: wineManager.Start, Stop: wineManager.Stop, + }) + return services +} diff --git a/internal/app/runtime_review.go b/internal/app/runtime_review.go new file mode 100644 index 0000000..f910fe6 --- /dev/null +++ b/internal/app/runtime_review.go @@ -0,0 +1,64 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// newRuntimeReviewServices registers review after AT-SPI, then optional browser +// mode and secure lock. Reverse shutdown therefore stops review before AT-SPI +// releases native callback state. +func newRuntimeReviewServices(logger *slog.Logger, commands commandListener, adapter Adapter, inputListener InputListener, focusGate *focusGate, speechOutput speechEnqueuer, speechMessages *speechWorker, brailleOutput *brailleWorker, wineCapture ...wineReviewCapturer) (*review.Worker, *browserModeController, []lifecycle.Service, error) { + var capturer review.TreeCapturer = adapter + if len(wineCapture) > 0 && wineCapture[0] != nil { + capturer = &reviewCaptureRouter{native: adapter, wine: wineCapture[0]} + } + objectReview, err := review.NewWorker(capturer, objectReviewCaptureTimeout) + if err != nil { + return nil, nil, nil, err + } + if err := objectReview.SetFlatCaptureTimeout(flatReviewCaptureTimeout); err != nil { + return nil, nil, nil, err + } + services := []lifecycle.Service{{Name: "object review", Start: objectReview.Start, Stop: objectReview.Stop}} + var browser *browserModeController + if keys, ok := commands.(browseKeyOwner); ok { + if capturer, ok := adapter.(documentContextCapturer); ok { + browser = newBrowserModeController(inputListener, keys, capturer) + services = append(services, lifecycle.Service{Name: "browser mode", Start: browser.Start, Stop: browser.Stop}) + } + } + focusGate.speech = speechOutput + lockMonitor := &screenLockMonitor{logger: logger, setActive: func(active bool) { + focusGate.SetLocked(active, adapter, inputListener, commands, speechMessages, brailleOutput) + objectReview.ClearFocus() + logger.Info("I38lock secure mode changed", "active", active) + }} + services = append(services, lifecycle.Service{Name: "screen lock", Start: func(ctx context.Context) error { + if err := lockMonitor.Start(ctx); err != nil { + logger.Warn("I38lock monitor unavailable", "error", err) + } + return nil + }, Stop: lockMonitor.Stop}) + return objectReview, browser, services, nil +} diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go new file mode 100644 index 0000000..97c7dbb --- /dev/null +++ b/internal/app/runtime_settings.go @@ -0,0 +1,117 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/sound" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func speechSettingsFromConfig(settings config.Settings) speech.Settings { + speechSettings := speech.Settings{ + OutputModule: settings.SpeechOutputModule, + Punctuation: map[int]string{0: "all", 1: "most", 2: "some", 3: "none"}[settings.VerbalizePunctuationStyle], + Capitalization: settings.CapitalizationStyle, + } + if settings.DefaultVoice.Rate != nil { + value := int(2*(*settings.DefaultVoice.Rate) - 98) + speechSettings.Rate = &value + } + if settings.DefaultVoice.AveragePitch != nil { + value := int(20*(*settings.DefaultVoice.AveragePitch) - 90) + speechSettings.Pitch = &value + } + if settings.DefaultVoice.Gain != nil { + value := int(15*(*settings.DefaultVoice.Gain) - 35) + speechSettings.Volume = &value + } + if family := settings.DefaultVoice.Family; family != nil { + speechSettings.Language = family.Lang + if family.Dialect != "" { + speechSettings.Language += "-" + family.Dialect + } + speechSettings.SynthesisVoice = family.Name + } + return speechSettings +} + +func inputEchoSettingsFromConfig(settings config.Settings) input.EchoSettings { + return input.EchoSettings{ + EnableKeyEcho: settings.EnableKeyEcho, + EnableModifierKeys: settings.EnableModifierKeys, + EnableFunctionKeys: settings.EnableFunctionKeys, + EnableActionKeys: settings.EnableActionKeys, + EnableNavigationKeys: settings.EnableNavigationKeys, + EnableDiacriticalKeys: settings.EnableDiacriticalKeys, + CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...), + } +} + +func presentationSettingsFromConfig(settings config.Settings, soundEnabled bool) presentation.Options { + return presentation.Options{ + Reading: &settings.Reading, + SpeakDescription: settings.SpeakDescription && !settings.Reading.OnlySpeakDisplayedText, + ReadFullRowInGUITable: settings.Reading.ReadFullRowInGUITable, + SpeechVerbosityLevel: settings.SpeechVerbosityLevel, + SpeakBlankLines: settings.Reading.SpeakBlankLines && !settings.Reading.OnlySpeakDisplayedText, + SayAllStyle: settings.SayAllStyle, + EnableSound: soundEnabled && soundConfiguration(settings).Enabled, + PlaySoundForRole: settings.PlaySoundForRole, + PlaySoundForState: settings.PlaySoundForState, + RoleSoundPresentation: settings.RoleSoundPresentation, + SpatializeObjectSounds: settings.SpatializeObjectSounds, + EnableKeyEcho: settings.EnableKeyEcho, + EnableEchoByCharacter: settings.EnableEchoByCharacter, + EnableEchoByWord: settings.EnableEchoByWord, + EnableEchoBySentence: settings.EnableEchoBySentence, + EnableAlphabeticKeys: settings.EnableAlphabeticKeys, + EnableNumericKeys: settings.EnableNumericKeys, + EnablePunctuationKeys: settings.EnablePunctuationKeys, + EnableSpace: settings.EnableSpace, + EnableDiacriticalKeys: settings.EnableDiacriticalKeys, + + PlaySoundForPositionInSet: settings.PlaySoundForPositionInSet, + } +} + +func soundConfiguration(settings config.Settings) sound.Configuration { + return sound.Configuration{ + Enabled: settings.EnableSound && settings.SoundTheme != "none" && settings.SoundVolume > 0, + Sink: settings.SoundSink, Theme: settings.SoundTheme, Volume: settings.SoundVolume, + } +} + +func captureSoundGeometry(settings config.Settings) bool { + return soundConfiguration(settings).Enabled && settings.SpatializeObjectSounds && + (settings.PlaySoundForRole || settings.PlaySoundForState || settings.PlaySoundForPositionInSet) +} + +func captureTextAttributes(settings config.Settings) bool { + if !settings.EnableBraille || settings.TextAttributes.Indicator == 0 { + return false + } + for _, choice := range settings.TextAttributes.Choices { + if choice.Braille { + return true + } + } + return false +} diff --git a/internal/app/runtime_settings_test.go b/internal/app/runtime_settings_test.go new file mode 100644 index 0000000..676cce6 --- /dev/null +++ b/internal/app/runtime_settings_test.go @@ -0,0 +1,117 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestSpeechSettingsFromConfig(t *testing.T) { + rate, pitch, gain := 75.0, 4.5, 5.0 + settings := config.DefaultSettings() + settings.VerbalizePunctuationStyle = 2 + settings.CapitalizationStyle = "icon" + settings.DefaultVoice = config.Voice{ + Rate: &rate, + AveragePitch: &pitch, + Gain: &gain, + Family: &config.VoiceFamily{Name: "Generic Voice", Lang: "en", Dialect: "US"}, + } + + got := speechSettingsFromConfig(settings) + if got.Rate == nil || *got.Rate != 52 || got.Pitch == nil || *got.Pitch != 0 || + got.Volume == nil || *got.Volume != 40 { + t.Fatalf("converted voice values = rate %v pitch %v volume %v", got.Rate, got.Pitch, got.Volume) + } + if got.Punctuation != "some" || got.Capitalization != "icon" || + got.Language != "en-US" || got.SynthesisVoice != "Generic Voice" { + t.Fatalf("converted speech settings = %#v", got) + } +} + +func TestSpeechSettingsLanguageWithoutPerson(t *testing.T) { + settings := config.DefaultSettings() + settings.DefaultVoice.Family = &config.VoiceFamily{Lang: "es"} + got := speechSettingsFromConfig(settings) + if got.Language != "es" || got.SynthesisVoice != "" { + t.Fatalf("language-only speech settings = %#v", got) + } +} + +func TestPresentationSettingsFromConfig(t *testing.T) { + settings := config.DefaultSettings() + settings.SpeakDescription = false + settings.PlaySoundForRole = true + settings.PlaySoundForState = true + settings.PlaySoundForPositionInSet = true + settings.RoleSoundPresentation = "speech_only" + settings.EnableKeyEcho = false + settings.EnableEchoByCharacter = true + settings.EnableEchoByWord = true + settings.EnableEchoBySentence = true + settings.EnableAlphabeticKeys = false + settings.EnableNumericKeys = false + settings.EnablePunctuationKeys = false + settings.EnableSpace = false + settings.EnableDiacriticalKeys = true + + got := presentationSettingsFromConfig(settings, true) + if got.SpeakDescription || !got.EnableSound || !got.PlaySoundForRole || !got.PlaySoundForState || !got.PlaySoundForPositionInSet || + got.RoleSoundPresentation != "speech_only" || got.EnableKeyEcho || !got.EnableEchoByCharacter || + !got.EnableEchoByWord || !got.EnableEchoBySentence || + got.EnableAlphabeticKeys || got.EnableNumericKeys || got.EnablePunctuationKeys || got.EnableSpace || + !got.EnableDiacriticalKeys { + t.Fatalf("presentation settings = %+v", got) + } + settings.EnableSound = false + settings.PlaySoundForPositionInSet = false + if got := presentationSettingsFromConfig(settings, true); got.PlaySoundForPositionInSet { + t.Fatal("disabled position setting remained enabled in presentation") + } + if got := presentationSettingsFromConfig(settings, true); got.EnableSound { + t.Fatal("disabled sound setting remained enabled in presentation") + } + settings.EnableSound = true + settings.SoundTheme = "none" + if got := presentationSettingsFromConfig(settings, true); got.EnableSound { + t.Fatal("none sound theme remained enabled in presentation") + } +} + +func TestInputEchoSettingsFromConfig(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableModifierKeys = false + settings.EnableFunctionKeys = false + settings.EnableActionKeys = false + settings.EnableNavigationKeys = true + settings.EnableDiacriticalKeys = true + settings.CthulhuModifierKeys = []string{"Insert"} + + got := inputEchoSettingsFromConfig(settings) + if !got.EnableKeyEcho || got.EnableModifierKeys || got.EnableFunctionKeys || got.EnableActionKeys || + !got.EnableNavigationKeys || !got.EnableDiacriticalKeys || len(got.CthulhuModifierKeys) != 1 || + got.CthulhuModifierKeys[0] != "Insert" { + t.Fatalf("input echo settings = %+v", got) + } + settings.CthulhuModifierKeys[0] = "KP_Insert" + if got.CthulhuModifierKeys[0] != "Insert" { + t.Fatal("input echo settings retained the mutable configuration slice") + } +} diff --git a/internal/app/runtime_shutdown.go b/internal/app/runtime_shutdown.go new file mode 100644 index 0000000..e0aa72a --- /dev/null +++ b/internal/app/runtime_shutdown.go @@ -0,0 +1,106 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "log/slog" + "time" + + "git.stormux.org/storm/cthulhu/internal/lifecycle" +) + +// runtimeShutdown owns the ordered teardown after the event loop returns. +// The worker channels must close before preferences, braille, and speech stop. +type runtimeShutdown struct { + tentacles *tentacleRuntime + ocr *ocrSession + logger *slog.Logger + cancel context.CancelFunc + workers *runtimeWorkers + preferences *preferencesWorker + braille *brailleWorker + speech *speechWorker + focusGate *focusGate + services *lifecycle.Runner +} + +func stopRuntime(runtime runtimeShutdown) error { + runtime.cancel() + var tentacleError error + if runtime.tentacles != nil { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + tentacleError = runtime.tentacles.Stop(ctx) + cancel() + } + <-runtime.workers.commandDone + if runtime.workers.internalUIDone != nil { + <-runtime.workers.internalUIDone + } + <-runtime.workers.clipboardDone + <-runtime.workers.inputDone + <-runtime.workers.copyDone + <-runtime.workers.autoClipboardDone + if runtime.ocr != nil { + ocrContext, cancelOCR := context.WithTimeout(context.Background(), 5*time.Second) + if err := runtime.ocr.Stop(ocrContext); err != nil { + runtime.logger.Warn("stop OCR", "error", err) + } + cancelOCR() + } + // Join settings work before farewell and backend shutdown. The preferences + // service also remains in the runner so failed cleanup is retried there. + if runtime.preferences != nil { + preferencesContext, cancelPreferences := context.WithTimeout(context.Background(), 7*time.Second) + if err := runtime.preferences.Stop(preferencesContext); err != nil { + runtime.logger.Warn("stop preferences", "error", err) + } + cancelPreferences() + } + brailleContext, cancelBraille := context.WithTimeout(context.Background(), shutdownTimeout) + if err := runtime.braille.Stop(brailleContext); err != nil { + runtime.logger.Warn("stop braille", "error", err) + } + cancelBraille() + if runtime.speech != nil && !runtime.focusGate.Locked() { + farewellContext, cancelFarewell := context.WithTimeout(context.Background(), farewellTimeout) + runtime.speech.InterruptForUser() + farewellError := runtime.speech.EnqueueAndWait(farewellContext, speechRequest{ + Text: random_string(shutdownMessages), + Role: "system", + Cause: "shutdown", + }) + cancelFarewell() + if farewellError != nil { + runtime.logger.Warn("deliver shutdown message", "error", farewellError) + } else { + runtime.logger.Debug("shutdown message accepted") + } + } + shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancelShutdown() + runtime.logger.Debug("stopping runtime services") + stopError := runtime.services.Stop(shutdownContext) + if stopError != nil { + runtime.logger.Warn("runtime services stopped with an error", "error", stopError) + } else { + runtime.logger.Debug("runtime services stopped") + } + return errors.Join(tentacleError, stopError) +} diff --git a/internal/app/runtime_test.go b/internal/app/runtime_test.go new file mode 100644 index 0000000..8f06148 --- /dev/null +++ b/internal/app/runtime_test.go @@ -0,0 +1,251 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestRandomString(t *testing.T) { + if got := random_string(nil); got != "" { + t.Fatalf("random_string(nil) = %q, want empty string", got) + } + if got := random_string([]string{"only"}); got != "only" { + t.Fatalf("random_string(single item) = %q, want %q", got, "only") + } +} + +func TestRunStopsSpeechAfterInputStartupFailure(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + inputListener := &fakeInputListener{ + events: make(chan input.Event), + modifierKeycodes: input.NewModifierKeycodes(37), + startError: errors.New("no X display"), + } + + err := Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), + newSpeaker: func() Speaker { return speaker }, + newCommands: func() commandListener { return silentCommands{} }, + newSound: func() soundService { return silentSound{} }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return inputListener }, + }) + if err == nil || !strings.Contains(err.Error(), "no X display") { + t.Fatalf("Run error = %v, want input startup failure", err) + } + if adapter.started { + t.Fatal("AT-SPI started after X11 listener startup failure") + } + if !speaker.closed { + t.Fatal("speech connection was not closed after X11 listener startup failure") + } +} + +func TestRunStopsStartedServicesAfterStartupFailure(t *testing.T) { + speaker := &fakeSpeaker{connectError: errors.New("connection refused"), spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + + err := Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), + newSpeaker: func() Speaker { return speaker }, + newCommands: func() commandListener { return silentCommands{} }, + newSound: func() soundService { return silentSound{} }, + newAdapter: func() Adapter { return adapter }, + }) + if err == nil { + t.Fatal("Run returned nil after speech connection failure") + } + if adapter.started { + t.Fatal("AT-SPI started despite speech startup failure") + } +} + +func TestRunHonorsDisabledSpeech(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + adapter := &fakeAdapter{events: make(chan atspi.Event)} + close(adapter.events) + speakerCreated := false + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + + err := Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), + Settings: &settings, + newSpeaker: func() Speaker { + speakerCreated = true + return speaker + }, + newCommands: func() commandListener { return silentCommands{} }, + newSound: func() soundService { return silentSound{} }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !speakerCreated { + t.Fatal("speech client was not available for live re-enabling") + } + if speaker.connected { + t.Fatal("speech backend connected while speech was disabled") + } + if !adapter.started || !adapter.stopped { + t.Fatal("AT-SPI lifecycle did not run while speech was disabled") + } +} + +func TestRunOpensPreferencesAtStartup(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + settings.EnableSound = false + adapter := &fakeAdapter{events: make(chan atspi.Event)} + commands := &fakeCommands{commands: make(chan input.CommandEvent, 1)} + preferencesCalled := make(chan bool, 1) + completed := make(chan error, 1) + + go func() { + completed <- Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), + Settings: &settings, + OpenPreferences: true, + newCommands: func() commandListener { return commands }, + onPreferences: func() { + select { + case preferencesCalled <- adapter.started: + default: + } + }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + + select { + case adapterStarted := <-preferencesCalled: + if !adapterStarted { + t.Fatal("preferences were requested before runtime services started") + } + case <-time.After(time.Second): + t.Fatal("startup preferences callback was not invoked") + } + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-completed: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("quit was not handled after startup preferences") + } +} + +func TestRunReachesSoundStopAfterEarlierServiceStopError(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + adapter := &fakeAdapter{ + events: make(chan atspi.Event, 1), + stopError: context.DeadlineExceeded, + } + sounds := &recordingSound{started: make(chan struct{})} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan error, 1) + + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), + Settings: &settings, + newSound: func() soundService { return sounds }, + newCommands: func() commandListener { + return silentCommands{} + }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + + select { + case <-sounds.started: + case <-time.After(time.Second): + t.Fatal("sound service did not start") + } + cancel() + select { + case err := <-completed: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Run error = %v, want deadline exceeded", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for Run to stop") + } + if !adapter.stopped { + t.Fatal("earlier service was not stopped") + } + if !sounds.stopped { + t.Fatal("final sound service was not reached") + } +} + +func TestRunReportsSoundStopError(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + soundError := errors.New("stop cue failed") + sounds := &recordingSound{started: make(chan struct{}), stopError: soundError} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan error, 1) + + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), + Settings: &settings, + newSound: func() soundService { return sounds }, + newCommands: func() commandListener { + return silentCommands{} + }, + newAdapter: func() Adapter { + return &fakeAdapter{events: make(chan atspi.Event, 1)} + }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + }) + }() + + select { + case <-sounds.started: + case <-time.After(time.Second): + t.Fatal("sound service did not start") + } + cancel() + select { + case err := <-completed: + if !errors.Is(err, soundError) { + t.Fatalf("Run error = %v, want sound stop error", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for Run to stop") + } +} diff --git a/internal/app/runtime_workers.go b/internal/app/runtime_workers.go new file mode 100644 index 0000000..e4a7e9c --- /dev/null +++ b/internal/app/runtime_workers.go @@ -0,0 +1,96 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// runtimeWorkers owns the event-loop companion workers. Only the event loop +// consumes reviewRequests; command and clipboard workers send to bounded queues. +// Each done channel closes after its worker exits. commandError is read after +// commandDone closes, so no additional synchronization is needed. +type runtimeWorkers struct { + reviewRequests <-chan struct{} + attributeRequests <-chan struct{} + commandDone <-chan struct{} + clipboardDone <-chan struct{} + inputDone <-chan struct{} + copyDone <-chan struct{} + autoClipboardDone <-chan struct{} + commandError error + internalUIDone <-chan struct{} + internalUI *internalUI +} + +// startRuntimeWorkers preserves startup order: clipboard, command, input, copy. +// The caller cancels ctx and joins these workers before stopping output services. +func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListener InputListener, settings config.Settings, copyChanges <-chan struct{}, braille *brailleWorker) *runtimeWorkers { + speech := gatedSpeech{speech: command.speechOutput, gate: command.focusGate} + learn := &learnModePresenter{tentacles: command.tentacles, ctx: ctx, logger: command.logger, speech: speech, braille: braille, live: command.live} + if owner, ok := command.commands.(interface{ BrowseNavigationEnabled() bool }); ok { + learn.browse = owner + } + command.learn = learn + clipboardRequests := make(chan struct{}, 1) + clipboardDone := runClipboardWorker(ctx, clipboardRequests, speech, readClipboardMessage) + command.clipboardRequests = clipboardRequests + reviewRequests := make(chan struct{}, 1) + command.reviewRequests = reviewRequests + attributeRequests := make(chan struct{}, 1) + command.attributeRequests = attributeRequests + commandDone := make(chan struct{}) + workers := &runtimeWorkers{ + reviewRequests: reviewRequests, + attributeRequests: attributeRequests, + commandDone: commandDone, + clipboardDone: clipboardDone, + } + if keys, ok := command.commands.(internalUIKeys); ok { + ui := &internalUI{notices: make(chan internalUINotice, 4), keys: keys, actions: command.actionSource, interest: command.objectReview, speech: speech, braille: braille, gate: command.focusGate, events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})} + command.internalUI = ui + if command.tentacles != nil { + command.tentacles.forms = ui + command.tentacles.focusedItem = focusedTentacleItem(command.actionSource, ui) + } + workers.internalUI = ui + workers.internalUIDone = ui.done + go ui.run(ctx) + } + go func() { + defer close(commandDone) + workers.commandError = command.run(ctx) + }() + + inputDone := make(chan struct{}) + if inputListener != nil { + go func() { + defer close(inputDone) + processInputEvents(ctx, command.logger, speech, inputListener, inputEchoSettingsFromConfig(settings), command.live, command.commands, command.ocr) + }() + } else { + close(inputDone) + } + workers.inputDone = inputDone + workers.copyDone = runCopyFeedback(ctx, copyChanges, inputListener, speech, command.live, command.ocr) + workers.autoClipboardDone = run_automatic_clipboard(ctx, command.logger, command.live, command.focusGate, speech) + return workers +} diff --git a/internal/app/screen_lock.go b/internal/app/screen_lock.go new file mode 100644 index 0000000..508b561 --- /dev/null +++ b/internal/app/screen_lock.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/sessionbus" + "github.com/godbus/dbus/v5" +) + +const screenLockBusName = "org.stormux.I38Lock1" + +type secureInputController interface{ SetSecureInputActive(bool) } + +// screenLockMonitor watches the same authenticated I38lock owner as Python. +// I38lock supplies its own feedback through the existing self-voice service. +type screenLockMonitor struct { + logger *slog.Logger + setActive func(bool) + connection *dbus.Conn + signals chan *dbus.Signal + cancel context.CancelFunc + done chan struct{} + owner string + active bool + trustedOwner func(context.Context, string) bool +} + +func (monitor *screenLockMonitor) Start(ctx context.Context) error { + connection, err := sessionbus.Connect(monitor.logger, "screen-lock") + if err != nil { + return err + } + monitor.connection = connection + monitor.signals = make(chan *dbus.Signal, 16) + connection.Signal(monitor.signals) + if err := connection.AddMatchSignal(screenLockMatch()...); err != nil { + connection.Close() + return err + } + monitor.trustedOwner = func(ctx context.Context, owner string) bool { + var pid uint32 + if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetConnectionUnixProcessID", 0, owner).Store(&pid); err != nil { + return false + } + executable, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) + return err == nil && filepath.Base(executable) == "i38lock" + } + monitor.refresh(ctx) + watchContext, cancel := context.WithCancel(ctx) + monitor.cancel, monitor.done = cancel, make(chan struct{}) + go func() { + defer close(monitor.done) + for { + select { + case <-watchContext.Done(): + return + case signal, ok := <-monitor.signals: + if !ok { + return + } + if signal != nil { + monitor.refresh(watchContext) + } + } + } + }() + return nil +} + +func screenLockMatch() []dbus.MatchOption { + return []dbus.MatchOption{dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchObjectPath("/org/freedesktop/DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(0, screenLockBusName)} +} + +func (monitor *screenLockMonitor) refresh(ctx context.Context) { + queryContext, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + var owner string + err := monitor.connection.BusObject().CallWithContext(queryContext, "org.freedesktop.DBus.GetNameOwner", 0, screenLockBusName).Store(&owner) + if err != nil { + var busError dbus.Error + if !errors.As(err, &busError) || busError.Name != "org.freedesktop.DBus.Error.NameHasNoOwner" { + monitor.logger.Warn("query I38lock owner", "error", err) + return // Do not release secure mode after a failed bus query. + } + owner = "" + } + monitor.updateOwner(queryContext, owner) +} + +func (monitor *screenLockMonitor) updateOwner(ctx context.Context, owner string) { + if owner == monitor.owner { + return + } + monitor.owner = owner + active := owner != "" && monitor.trustedOwner(ctx, owner) + if owner != "" && !active { + monitor.logger.Warn("ignoring untrusted I38lock bus owner") + } + if active != monitor.active { + monitor.active = active + monitor.setActive(active) + } +} + +func (monitor *screenLockMonitor) Stop(ctx context.Context) error { + if monitor.cancel == nil { + return nil + } + monitor.cancel() + select { + case <-monitor.done: + case <-ctx.Done(): + return ctx.Err() + } + monitor.connection.RemoveSignal(monitor.signals) + _ = monitor.connection.RemoveMatchSignal(screenLockMatch()...) + return monitor.connection.Close() +} + +func (gate *focusGate) Locked() bool { return gate != nil && gate.secure.Load() } + +func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputListener, commands commandListener, speech *speechWorker, display *brailleWorker) { + if active { + gate.ocr.Secure() + } + gate.mutex.Lock() + defer gate.mutex.Unlock() + if !gate.secure.CompareAndSwap(!active, active) { + return + } + gate.retireTentacleOutput() + for _, target := range []any{adapter, listener, commands} { + if controller, ok := target.(secureInputController); ok { + controller.SetSecureInputActive(active) + } + } + if gate.commands != nil { + gate.commands.SetCommandGrabs(!active && !gate.suspended.Load()) + } + if speech != nil { + speech.SetSecureInputActive(active) + } + display.SetSecureInputActive(active) + select { + case gate.lockChanges <- struct{}{}: + default: + } +} + +func (worker *speechWorker) SetSecureInputActive(active bool) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + worker.secure = active + if active { + worker.startupUntil = time.Time{} + worker.interruptLocked() + } +} + +func (worker *brailleWorker) SetSecureInputActive(active bool) { + worker.mu.Lock() + worker.secure = active + // Retire flash restoration and routing targets even without a display. + worker.protectedFlash = false + worker.flashContext = nil + worker.flashGeneration++ + if worker.flashTimer != nil { + worker.flashTimer.Stop() + worker.flashTimer = nil + } + if worker.routeCancel != nil { + worker.routeCancel() + } + worker.frame, worker.baseFrame = braille.Frame{Cursor: -1}, braille.Frame{Cursor: -1} + worker.internalUIFrame = nil + worker.enqueueSecureFrameLocked(braille.Frame{Cursor: -1}, true) + worker.mu.Unlock() + worker.wakeDisplay() +} + +func (worker *brailleWorker) EnqueueSecureMessage(text string) bool { + if text == "" || len(text) > 10000 { + return false + } + worker.mu.Lock() + queued := worker.connected && worker.enqueueSecureFrameLocked(braille.Frame{Text: text, Cursor: -1}, true) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } + return queued +} + +type lockFeedbackBraille struct{ worker *brailleWorker } + +func (display lockFeedbackBraille) EnqueueMessage(text string) bool { + return display.worker.EnqueueSecureMessage(text) +} diff --git a/internal/app/screen_lock_test.go b/internal/app/screen_lock_test.go new file mode 100644 index 0000000..d304303 --- /dev/null +++ b/internal/app/screen_lock_test.go @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "github.com/godbus/dbus/v5" +) + +func TestScreenLockRejectsLocalOutputButPreservesRemoteFeedback(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + worker := newSpeechWorker(logger, nil) + worker.running = true // Retain requests for inspection without backend I/O. + worker.EnqueueStartup(speechRequest{Text: "Welcome", Cause: "startup"}) + display := &brailleWorker{cancel: func() {}, connected: true, wake: make(chan struct{}, 1)} + display.EnqueueFrame(braille.Frame{Text: "Desktop", Cursor: 0}) + display.Flash("Notice", config.BrailleOptions{FlashMessages: true, FlashPersistent: true}) + commands := &commandGrabRecorder{} + gate := newFocusGate(commands) + gate.speech = worker + gate.SetLocked(true, nil, nil, commands, worker, display) + if len(worker.pending) != 0 || !worker.startupUntil.IsZero() || display.current().Text != "" || display.baseFrame.Text != "" { + t.Fatal("lock retained desktop presentation or startup protection") + } + // A terminal focus transition must never reopen command grabs during lock. + gate.SetSuspended(true) + gate.SetSuspended(false) + for _, enabled := range commands.grabs() { + if enabled { + t.Fatal("terminal focus restored grabs while locked") + } + } + guarded := gatedSpeech{speech: worker, gate: gate} + guarded.Enqueue(speechRequest{Text: "Private desktop", BypassFocusGate: true}) + presentNotifications(guarded, []string{"Notification ready"}) + worker.Enqueue(speechRequest{Text: "Notification", Cause: "notification"}) + if guarded.InterruptForInput(input.Event{Pressed: true}) { + t.Fatal("password key interrupted locker feedback") + } + display.Enqueue("Private desktop") + display.EnqueueMessage("Notification") + if len(worker.pending) != 0 || display.current().Text != "" { + t.Fatal("local output escaped secure mode") + } + remote := remoteSpeech{speech: worker, braille: lockFeedbackBraille{display}} + if !remote.PresentMessage("Screen locked") || len(worker.pending) != 1 || worker.pending[0].Text != "Screen locked" || display.current().Text != "Screen locked" { + t.Fatal("I38lock feedback was suppressed") + } + gate.SetLocked(false, nil, nil, commands, worker, display) + if display.current().Text != "" { + t.Fatal("unlock retained locker feedback") + } + guarded.Enqueue(speechRequest{Text: "Desktop resumed"}) + if len(worker.pending) != 2 || !commands.grabs()[len(commands.grabs())-1] { + t.Fatal("unlock did not resume desktop presentation and grabs") + } +} + +func TestScreenLockOwnerAuthenticationAndReplacement(t *testing.T) { + var states []bool + monitor := &screenLockMonitor{logger: slog.New(slog.DiscardHandler), setActive: func(active bool) { states = append(states, active) }, trustedOwner: func(_ context.Context, owner string) bool { return owner == ":1.42" }} + for _, owner := range []string{":1.41", ":1.42", ":1.42", "", ":1.43"} { + monitor.updateOwner(context.Background(), owner) + } + if !reflect.DeepEqual(states, []bool{true, false}) { + t.Fatalf("lock transitions = %v", states) + } +} + +// Executed under a copied test executable named i38lock, so /proc/PID/exe +// exercises the production owner authentication rather than a mocked PID. +func TestI38lockOwnerHelper(t *testing.T) { + if os.Getenv("CTHULHU_LOCK_HELPER") != "1" { + t.Skip("subprocess helper") + } + connection, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + if reply, err := connection.RequestName(screenLockBusName, dbus.NameFlagDoNotQueue); err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + t.Fatalf("request lock name: %v, %v", reply, err) + } + fmt.Println("ready") + var input [1]byte + _, _ = os.Stdin.Read(input[:]) + _, _ = connection.ReleaseName(screenLockBusName) +} + +func TestNativeI38lockOwnerStartupUnlockAndSpoofing(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("requires private session bus") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "i38lock") + if err := os.WriteFile(path, data, 0700); err != nil { + t.Fatal(err) + } + process := exec.CommandContext(ctx, path, "-test.run=^TestI38lockOwnerHelper$") + process.Env = append(os.Environ(), "CTHULHU_LOCK_HELPER=1") + stdout, err := process.StdoutPipe() + if err != nil { + t.Fatal(err) + } + stdin, err := process.StdinPipe() + if err != nil { + t.Fatal(err) + } + process.Stderr = os.Stderr + if err := process.Start(); err != nil { + t.Fatal(err) + } + defer process.Process.Kill() + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil || line != "ready\n" { + t.Fatalf("helper ready = %q, %v", line, err) + } + states := make(chan bool, 8) + monitor := &screenLockMonitor{logger: slog.New(slog.DiscardHandler), setActive: func(active bool) { states <- active }} + if err := monitor.Start(ctx); err != nil { + t.Fatal(err) + } + defer monitor.Stop(context.Background()) + expect := func(want bool) { + t.Helper() + select { + case got := <-states: + if got != want { + t.Fatalf("secure state = %v, want %v", got, want) + } + case <-ctx.Done(): + t.Fatal("missing secure state transition") + } + } + expect(true) // Reader started after the screen was already locked. + stdin.Close() + if err := process.Wait(); err != nil { + t.Fatal(err) + } + expect(false) + spoof, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer spoof.Close() + if _, err := spoof.RequestName(screenLockBusName, dbus.NameFlagDoNotQueue); err != nil { + t.Fatal(err) + } + select { + case <-states: + t.Fatal("untrusted owner changed secure state") + case <-time.After(100 * time.Millisecond): + } + _, _ = spoof.ReleaseName(screenLockBusName) + // Verify a trusted owner appearing after subscription as well as startup. + process = exec.CommandContext(ctx, path, "-test.run=^TestI38lockOwnerHelper$") + process.Env = append(os.Environ(), "CTHULHU_LOCK_HELPER=1") + stdout, err = process.StdoutPipe() + if err != nil { + t.Fatal(err) + } + stdin, err = process.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := process.Start(); err != nil { + t.Fatal(err) + } + defer process.Process.Kill() + line, err = bufio.NewReader(stdout).ReadString('\n') + if err != nil || line != "ready\n" { + t.Fatalf("helper ready = %q, %v", line, err) + } + expect(true) + stdin.Close() + if err := process.Wait(); err != nil { + t.Fatal(err) + } + expect(false) +} diff --git a/internal/app/selection_native_test.go b/internal/app/selection_native_test.go new file mode 100644 index 0000000..7f49739 --- /dev/null +++ b/internal/app/selection_native_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestNativeBrowserSelection(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + keys := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys %s: %v %s", chord, err, out) + } + } + var entry atspi.Snapshot + for attempt := 0; attempt < 30 && !entry.ID().Valid(); attempt++ { + keys("Tab") + timer := time.NewTimer(250 * time.Millisecond) + waitFocus: + for { + select { + case event := <-adapter.Events(): + if isFocusEvent(event.Type) && event.Source.Name == "Display name" && event.Source.Editable { + entry = event.Source + break waitFocus + } + case <-timer.C: + break waitFocus + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + timer.Stop() + } + if !entry.ID().Valid() { + t.Fatal("editable browser input not focused") + } + if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "--delay", "20", "hello world").CombinedOutput(); err != nil { + t.Fatalf("type fixture text: %v %s", err, out) + } + waitSelection := func(want string) { + t.Helper() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + for { + select { + case event := <-adapter.Events(): + if event.Type == atspi.TextSelectionChangedEvent && event.Source.ID() == entry.ID() && + strings.Contains(event.Source.SelectionText, want) { + if event.Source.SelectionEnd <= event.Source.SelectionStart { + t.Fatalf("selection range missing: %+v", event.Source) + } + return + } + case <-deadline.C: + t.Fatalf("no native selection containing %q", want) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + keys("ctrl+a") + waitSelection("hello world") + keys("Right") + keys("shift+Left") + waitSelection("d") + keys("ctrl+shift+Left") + waitSelection("world") + waitMutation := func(eventType string) { + t.Helper() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + for { + select { + case event := <-adapter.Events(): + if event.Type == eventType && event.Source.ID() == entry.ID() { + return + } + case <-deadline.C: + t.Fatalf("no native %s in browser input", eventType) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + keys("ctrl+x") + waitMutation(atspi.TextDeletedEvent) + keys("ctrl+v") + waitMutation(atspi.TextInsertedEvent) +} diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go new file mode 100644 index 0000000..3c1e565 --- /dev/null +++ b/internal/app/speech_delivery.go @@ -0,0 +1,238 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "unicode/utf8" +) + +func (worker *speechWorker) deliver(ctx context.Context, request speechRequest) error { + if err := ctx.Err(); err != nil { + return err + } + // Suppressed utterances must not cancel the spoken mode announcement. + // Mode changes already interrupt the synth through the worker barrier. + if handled, err := worker.deliver_mode(ctx, request); handled { + return err + } + if !request.Append { + if err := worker.speaker.Cancel(ctx); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + worker.logger.Warn("cancel previous speech", "role", request.Role, "error", err) + } + } + if len(request.Spelling) != 0 { + // One parent request owns its context; letter recursion must not + // re-announce it or alter the original character's voice choice. + if err := worker.deliver_context(ctx, request); err != nil { + return err + } + request.Context = nil + parts := request.Spelling + voices := request.SpellingVoices + request.Spelling = nil + request.SpellingVoices = nil + request.Append = true + for index, part := range parts { + worker.mutex.Lock() + interrupted := worker.generation != request.deliveryGeneration + worker.mutex.Unlock() + if interrupted { + return context.Canceled + } + request.Text = part + request.VoiceType = "" + if index < len(voices) { + request.VoiceType = voices[index] + } + if err := worker.deliver(ctx, request); err != nil { + return err + } + } + return nil + } + if err := worker.deliver_context(ctx, request); err != nil { + return err + } + if len(request.ReadAll) != 0 { + reader, ok := worker.speaker.(interface { + SpeakAndWait(context.Context, string, func()) error + }) + if !ok { + return errors.New("speech backend does not support continuous reading") + } + for index, part := range request.ReadAll { + if err := ctx.Err(); err != nil { + return err + } + if part.Context != nil { + if err := worker.deliver_context_for(ctx, part.Context, true); err != nil { + return err + } + } + text := worker.transformText(worker.line_text(part.Text, part.Line)) + worker.logDelivery(ctx, request, text, index) + if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" { + worker.history.add(text, request.Role) + } + var err error + if voiced, ok := worker.speaker.(interface { + SpeakVoiceAndWait(context.Context, string, string, func()) error + }); ok { + err = voiced.SpeakVoiceAndWait(ctx, text, part.VoiceType, part.Began) + } else { + err = reader.SpeakAndWait(ctx, text, part.Began) + } + if err != nil { + return err + } + } + return nil + } + if request.Uninterruptible { + reader, ok := worker.speaker.(interface { + speak_uninterruptible(context.Context, string) error + }) + if !ok { + return errors.New("speech backend does not support uninterruptible completion") + } + text := worker.transformText(worker.line_text(request.Text, request.Line)) + worker.record_delivery(ctx, request, text) + return reader.speak_uninterruptible(ctx, text) + } + if request.Confirmation { + text := worker.transformText(worker.line_text(request.Text, request.Line)) + worker.record_delivery(ctx, request, text) + if voiced, ok := worker.speaker.(interface { + SpeakVoiceAndWait(context.Context, string, string, func()) error + }); ok { + return voiced.SpeakVoiceAndWait(ctx, text, "system", nil) + } + reader, ok := worker.speaker.(interface { + SpeakAndWait(context.Context, string, func()) error + }) + if !ok { + return errors.New("speech backend does not support confirmation completion") + } + return reader.SpeakAndWait(ctx, text, nil) + } + speak := worker.speaker.Speak + kind := request.VoiceType + if kind == "" { + switch request.Role { + case "system": + kind = "system" + case "link": + kind = "hyperlink" + } + } + if voiced, ok := worker.speaker.(interface { + SpeakVoice(context.Context, string, string) error + }); ok { + speak = func(ctx context.Context, text string) error { return voiced.SpeakVoice(ctx, text, kind) } + } + if request.Character { + if character, ok := worker.speaker.(interface { + SpeakCharacter(context.Context, string) error + }); ok { + speak = character.SpeakCharacter + } + } + if request.CustomEcho && request.Character { + if echo, ok := worker.speaker.(interface { + SpeakEchoCharacter(context.Context, string) error + }); ok { + speak = echo.SpeakEchoCharacter + } + } else if request.CustomEcho { + if echo, ok := worker.speaker.(interface { + SpeakEcho(context.Context, string) error + }); ok { + speak = echo.SpeakEcho + } + } + chunks := request.Chunks + if len(chunks) == 0 { + chunks = []string{request.Text} + } + for index, chunk := range chunks { + if err := ctx.Err(); err != nil { + return err + } + worker.mutex.Lock() + interrupted := worker.generation != request.deliveryGeneration + worker.mutex.Unlock() + if interrupted { + return context.Canceled + } + if index == 0 { + chunk = worker.line_text(chunk, request.Line) + } + text := chunk + // Character navigation and literal spelling must retain the original + // letter and case. History rows contain the transformed delivery. + // Spoken character names still accept pronunciation replacements. + historyRow := request.ExcludeFromHistory && request.Role == "table cell" + if !historyRow && !(request.Character && utf8.RuneCountInString(chunk) == 1) { + text = worker.transformText(chunk) + } + part := request + part.Append = request.Append || index > 0 + worker.record_delivery(ctx, part, text) + if err := speak(ctx, text); err != nil { + return fmt.Errorf("speak: %w", err) + } + } + return nil +} + +func (worker *speechWorker) record_delivery(ctx context.Context, request speechRequest, text string) { + worker.logDelivery(ctx, request, text, -1) + if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" { + worker.history.add(text, request.Role) + } +} + +func (worker *speechWorker) transformText(text string) string { + if worker.textTransform != nil { + return worker.textTransform(text) + } + return text +} + +// Log the text passed to the backend, including each Say All part rather than +// the entire queued batch. A delivery record is an attempt, not audible proof. +func (worker *speechWorker) logDelivery(ctx context.Context, request speechRequest, text string, readAllIndex int) { + if !worker.logger.Enabled(ctx, slog.LevelDebug) { + return + } + if request.Role == "password text" { + text = "[REDACTED]" + } + worker.logger.DebugContext(ctx, "delivering accessible presentation", + "role", request.Role, "cause", request.Cause, "utterance", text, + "append", request.Append, "interrupt_previous", !request.Append && readAllIndex <= 0, + "character", request.Character, "custom_echo", request.CustomEcho, + "read_all_index", readAllIndex) +} diff --git a/internal/app/speech_enable_test.go b/internal/app/speech_enable_test.go new file mode 100644 index 0000000..4c9d460 --- /dev/null +++ b/internal/app/speech_enable_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func TestPreferencesEnableAndDisableSpeechDuringSession(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + speechWorker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + speechWorker.SetEnabled(false) + if err := speechWorker.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer speechWorker.Stop(context.Background()) + updates := 0 + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, + settingsUpdateFunc(func(context.Context, speech.Settings) error { updates++; return nil })) + worker.speechWorker = speechWorker + enabled := settings + enabled.EnableSpeech = true + if err := worker.applyRuntime(context.Background(), enabled); err != nil { + t.Fatal(err) + } + if updates != 1 { + t.Fatalf("enabling speech made %d backend updates, want 1", updates) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := speechWorker.EnqueueAndWait(ctx, speechRequest{Text: "enabled"}); err != nil { + t.Fatal(err) + } + if got := <-speaker.spoken; got != "enabled" { + t.Fatalf("spoken text = %q", got) + } + if err := worker.applyRuntime(context.Background(), settings); err != nil { + t.Fatal(err) + } + if updates != 1 { + t.Fatal("disabling speech reconnected the backend") + } + if err := speechWorker.EnqueueAndWait(ctx, speechRequest{Text: "disabled"}); !errors.Is(err, context.Canceled) { + t.Fatalf("disabled speech returned %v, want canceled", err) + } + select { + case got := <-speaker.spoken: + t.Fatalf("speech escaped disabled mode: %q", got) + default: + } +} diff --git a/internal/app/speech_history.go b/internal/app/speech_history.go new file mode 100644 index 0000000..7eca66c --- /dev/null +++ b/internal/app/speech_history.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "sync" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +// speechHistory retains the most recent distinct utterances. The history +// window suspends capture so its own feedback never changes the list. +type speechHistory struct { + mu sync.Mutex + items []string + limit int + paused bool + monitor chan string +} + +func newSpeechHistory(limit int) *speechHistory { + if limit < config.MinSpeechHistoryLimit || limit > config.MaxSpeechHistoryLimit { + limit = config.DefaultSpeechHistoryLimit + } + return &speechHistory{limit: limit} +} + +func (h *speechHistory) setLimit(limit int) { + if limit < config.MinSpeechHistoryLimit || limit > config.MaxSpeechHistoryLimit { + return + } + h.mu.Lock() + defer h.mu.Unlock() + h.limit = limit + if len(h.items) > limit { + clear(h.items[limit:]) + h.items = h.items[:limit] + } +} + +func (h *speechHistory) add(text, role string) { + if role == "password text" { + return + } + text = strings.TrimSpace(text) + if text == "" { + return + } + h.mu.Lock() + defer h.mu.Unlock() + if h.monitor != nil { + select { + case h.monitor <- text: + default: + } + } + if h.paused { + return + } + for _, item := range h.items { + if item == text { + return + } + } + h.items = append(h.items, "") + copy(h.items[1:], h.items[:len(h.items)-1]) + h.items[0] = text + if len(h.items) > h.limit { + clear(h.items[h.limit:]) + h.items = h.items[:h.limit] + } +} + +func (h *speechHistory) snapshot() []string { + h.mu.Lock() + defer h.mu.Unlock() + return append([]string(nil), h.items...) +} + +func (h *speechHistory) remove(text string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for i, item := range h.items { + if item == text { + h.items = append(h.items[:i], h.items[i+1:]...) + return true + } + } + return false +} + +func (h *speechHistory) setPaused(paused bool) { + h.mu.Lock() + h.paused = paused + h.mu.Unlock() +} + +func (h *speechHistory) setMonitor(ch chan string) { + h.mu.Lock() + h.monitor = ch + h.mu.Unlock() +} diff --git a/internal/app/speech_history_test.go b/internal/app/speech_history_test.go new file mode 100644 index 0000000..d4eb637 --- /dev/null +++ b/internal/app/speech_history_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func TestSpeechHistoryRetentionPauseAndRemoval(t *testing.T) { + h := newSpeechHistory(config.DefaultSpeechHistoryLimit) + h.add(" first ", "system") + h.add("first", "system") + h.add("secret", "password text") + for i := 0; i < 50; i++ { + h.add(fmt.Sprintf("item %d", i), "label") + } + items := h.snapshot() + if len(items) != 50 || items[0] != "item 49" || items[49] != "item 0" { + t.Fatalf("unexpected retained history: %v", items) + } + h.setPaused(true) + h.add("window feedback", "system") + if len(h.snapshot()) != 50 { + t.Fatal("paused speech changed history") + } + h.setPaused(false) + if !h.remove("item 20") || h.remove("item 20") { + t.Fatal("removal result is wrong") + } + h.add("item 20", "label") + if got := h.snapshot()[0]; got != "item 20" { + t.Fatalf("re-added item = %q", got) + } +} + +func TestSpeechHistoryLimitTrimsOldestAndCanGrow(t *testing.T) { + h := newSpeechHistory(1000) + for i := range 1001 { + h.add(fmt.Sprintf("item %d", i), "label") + } + if items := h.snapshot(); len(items) != 1000 || items[0] != "item 1000" || items[999] != "item 1" { + t.Fatalf("maximum history retention = %d items", len(items)) + } + h.setLimit(10) + items := h.snapshot() + if len(items) != 10 || items[0] != "item 1000" || items[9] != "item 991" { + t.Fatalf("trimmed history = %v", items) + } + h.setLimit(1000) + h.add("new item", "label") + if items = h.snapshot(); len(items) != 11 || items[0] != "new item" { + t.Fatalf("grown history = %v", items) + } +} + +func TestPreferencesApplyHistoryLimitLiveAndPersisted(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + history := newSpeechHistory(settings.SpeechHistoryLimit) + for i := range 20 { + history.add(fmt.Sprintf("item %d", i), "label") + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.history = history + refreshes := 0 + worker.refreshHistory = func() { refreshes++ } + values := preferences.FromSettings(settings) + values.SpeechHistoryLimit = 10 + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || len(history.snapshot()) != 10 || refreshes != 1 { + t.Fatalf("limit was not applied immediately: response=%+v, count=%d, refreshes=%d", response, len(history.snapshot()), refreshes) + } + loaded, err := config.Load(settings.Path) + if err != nil || loaded.SpeechHistoryLimit != 10 { + t.Fatalf("saved history limit = %d, error %v", loaded.SpeechHistoryLimit, err) + } + for _, invalid := range []int{0, 1001} { + values.SpeechHistoryLimit = invalid + response = worker.apply(context.Background(), preferences.Request{Values: values}) + if response.Accepted || len(history.snapshot()) != 10 || refreshes != 1 { + t.Fatalf("invalid limit %d changed history: response=%+v", invalid, response) + } + } +} + +func TestSpeechHistoryMonitorIncludesDuplicatesWhileCapturePaused(t *testing.T) { + h := newSpeechHistory(config.DefaultSpeechHistoryLimit) + stream := make(chan string, 3) + h.setMonitor(stream) + h.setPaused(true) + h.add(" repeated ", "system") + h.add("repeated", "system") + h.add("private", "password text") + for i := 0; i < 2; i++ { + if got := <-stream; got != "repeated" { + t.Fatalf("monitor line = %q", got) + } + } + if len(stream) != 0 || len(h.snapshot()) != 0 { + t.Fatal("monitor leaked a password or captured paused speech") + } +} + +func TestSpeechDeliveryRecordsHistory(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 4)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.history = newSpeechHistory(config.DefaultSpeechHistoryLimit) + for _, request := range []speechRequest{ + {Text: "Delivered text", Role: "label"}, + {Text: "private text", Role: "password text"}, + {Text: "History row spoken after the window closes", Role: "table cell", ExcludeFromHistory: true}, + {Text: "Copied to clipboard.", Role: "system", Cause: "speech-history"}, + } { + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + } + items := worker.history.snapshot() + if len(items) != 1 || items[0] != "Delivered text" { + t.Fatalf("delivery history = %v", items) + } +} + +func TestSpeechHistoryWindowEventsDoNotEnterHistory(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + + historyWindow := atspi.NewObjectID(":1.73", "/history") + history := atspi.NewSnapshot(historyWindow.Path, speechHistoryWindowTitle, "frame", "", "") + history.BusName = historyWindow.BusName + activated := atspi.NewEvent(atspi.WindowActivatedEvent, history) + activated.Window = historyWindow + activated.WindowTitle = speechHistoryWindowTitle + adapter.events <- activated + + row := atspi.NewSnapshot("/history/row", "Previously spoken text", "table cell", "", "") + row.BusName = historyWindow.BusName + row.Focused = true + focused := atspi.NewEvent(atspi.FocusedStateEvent, row) + focused.Window = historyWindow + focused.Detail1 = 1 + adapter.events <- focused + + otherWindow := atspi.NewObjectID(":1.80", "/other") + other := atspi.NewSnapshot(otherWindow.Path, "Other window", "frame", "", "") + other.BusName = otherWindow.BusName + otherActivated := atspi.NewEvent(atspi.WindowActivatedEvent, other) + otherActivated.Window = otherWindow + otherActivated.WindowTitle = "Other window" + adapter.events <- otherActivated + + for index, excluded := range []bool{true, true, false} { + select { + case request := <-speech.requests: + if request.ExcludeFromHistory != excluded { + t.Fatalf("request %d (%q) excludes history = %t, want %t", index, request.Text, request.ExcludeFromHistory, excluded) + } + if index == 1 { + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.textTransform = func(text string) string { + return pronunciationSpeechText(text, []config.PronunciationEntry{{Word: "Previously", Replacement: "Changed"}}) + } + if err := worker.deliver(ctx, request); err != nil { + t.Fatal(err) + } + if got := strings.Join(speaker.parts, " "); !strings.Contains(got, "Previously spoken text") { + t.Fatalf("history row changed during delivery: %q", got) + } + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for request %d", index) + } + } +} + +func TestRunRoutesSpeechHistoryCommands(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableSpeech = false + settings.EnableSound = false + commands := &fakeCommands{commands: make(chan input.CommandEvent, 3)} + called := make(chan input.Command, 2) + done := make(chan error, 1) + go func() { + done <- Run(context.Background(), Options{ + Logger: slog.New(slog.DiscardHandler), Settings: &settings, + newCommands: func() commandListener { return commands }, + newAdapter: func() Adapter { return &fakeAdapter{events: make(chan atspi.Event)} }, + newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, + onSpeechHistory: func() { called <- input.ShowSpeechHistory }, + onSpeechMonitor: func() { called <- input.ToggleSpeechMonitor }, + }) + }() + for _, command := range []input.Command{input.ShowSpeechHistory, input.ToggleSpeechMonitor} { + commands.commands <- input.CommandEvent{Command: command} + select { + case got := <-called: + if got != command { + t.Fatalf("routed %s, want %s", got, command) + } + case <-time.After(time.Second): + t.Fatalf("command %s was not routed", command) + } + } + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("quit was not handled") + } +} diff --git a/internal/app/speech_history_window.go b/internal/app/speech_history_window.go new file mode 100644 index 0000000..75f37ff --- /dev/null +++ b/internal/app/speech_history_window.go @@ -0,0 +1,261 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "encoding/json" + "io" + "log/slog" + "os" + "os/exec" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/platform/session" + "git.stormux.org/storm/cthulhu/internal/speechhistoryui" +) + +const speechHistoryWindowTitle = "Speech History - Cthulhu" + +type speechHistoryWindows struct { + logger *slog.Logger + history *speechHistory + speech speechEnqueuer + gate *focusGate + open chan struct{} + refresh chan struct{} + toggle chan struct{} + done chan struct{} + cancel context.CancelFunc + once sync.Once +} + +func newSpeechHistoryWindows(logger *slog.Logger, history *speechHistory, speech speechEnqueuer, gate *focusGate) *speechHistoryWindows { + return &speechHistoryWindows{logger: logger, history: history, speech: speech, gate: gate, + open: make(chan struct{}, 1), refresh: make(chan struct{}, 1), toggle: make(chan struct{}, 1), done: make(chan struct{})} +} +func (w *speechHistoryWindows) Refresh() { + select { + case w.refresh <- struct{}{}: + default: + } +} +func (w *speechHistoryWindows) Open() { + select { + case w.open <- struct{}{}: + default: + } +} +func (w *speechHistoryWindows) ToggleMonitor() { + select { + case w.toggle <- struct{}{}: + default: + } +} +func (w *speechHistoryWindows) Start(ctx context.Context) error { + ctx, w.cancel = context.WithCancel(ctx) + go w.run(ctx) + return nil +} +func (w *speechHistoryWindows) Stop(ctx context.Context) error { + w.once.Do(func() { + if w.cancel != nil { + w.cancel() + } + }) + select { + case <-w.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (w *speechHistoryWindows) announce(text string) { + if w.gate != nil && (w.gate.Locked() || w.gate.Suspended()) { + return + } + w.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "speech-history"}) +} +func (w *speechHistoryWindows) run(ctx context.Context) { + defer close(w.done) + var historyUpdates, monitorUpdates chan speechhistoryui.Message + var historyDone, monitorDone chan struct{} + var stopHistory, stopMonitor context.CancelFunc + defer func() { + if stopHistory != nil { + stopHistory() + } + if stopMonitor != nil { + stopMonitor() + } + if historyDone != nil { + <-historyDone + } + if monitorDone != nil { + <-monitorDone + } + w.history.setPaused(false) + w.history.setMonitor(nil) + }() + for { + select { + case <-ctx.Done(): + return + case <-w.open: + if historyUpdates != nil { + select { + case historyUpdates <- speechhistoryui.Message{Present: true}: + default: + } + continue + } + childCtx, cancel := context.WithCancel(ctx) + stopHistory = cancel + historyUpdates = make(chan speechhistoryui.Message, 4) + historyDone = make(chan struct{}) + w.history.setPaused(true) + go func() { + defer close(historyDone) + if err := w.window(childCtx, "history", historyUpdates, nil); err != nil && childCtx.Err() == nil { + w.logger.Error("speech history window", "error", err) + } + }() + case <-w.refresh: + if historyUpdates != nil { + update := speechhistoryui.Message{Items: append([]string{}, w.history.snapshot()...)} + select { + case historyUpdates <- update: + case <-ctx.Done(): + return + } + } + case <-historyDone: + historyUpdates, historyDone = nil, nil + stopHistory = nil + w.history.setPaused(false) + case <-w.toggle: + if monitorUpdates != nil { + stopMonitor() + w.announce("Speech monitor disabled.") + continue + } + childCtx, cancel := context.WithCancel(ctx) + stopMonitor = cancel + monitorUpdates = make(chan speechhistoryui.Message, 128) + monitorDone = make(chan struct{}) + stream := make(chan string, 128) + w.history.setMonitor(stream) + go func() { + defer close(monitorDone) + if err := w.window(childCtx, "monitor", monitorUpdates, stream); err != nil && childCtx.Err() == nil { + w.logger.Error("speech monitor window", "error", err) + } + }() + w.announce("Speech monitor enabled.") + case <-monitorDone: + monitorUpdates, monitorDone = nil, nil + stopMonitor = nil + w.history.setMonitor(nil) + } + } +} + +func (w *speechHistoryWindows) window(ctx context.Context, mode string, updates <-chan speechhistoryui.Message, stream <-chan string) error { + executable, err := os.Executable() + if err != nil { + return err + } + childCtx, stop := context.WithCancel(ctx) + defer stop() + command := exec.CommandContext(childCtx, executable, "--speech-history-ui") + command.Env = session.GTKEnvironment() + command.WaitDelay = time.Second + in, err := command.StdinPipe() + if err != nil { + return err + } + out, err := command.StdoutPipe() + if err != nil { + in.Close() + return err + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + in.Close() + out.Close() + return err + } + defer func() { stop(); in.Close(); out.Close(); _ = command.Wait() }() + encoder := json.NewEncoder(in) + initial := speechhistoryui.Message{Mode: mode} + if mode == "history" { + initial.Items = append([]string{}, w.history.snapshot()...) + } + if err := encoder.Encode(initial); err != nil { + return err + } + requests := make(chan speechhistoryui.Request, 1) + readDone := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 4096), 1024*1024) + for scanner.Scan() { + var request speechhistoryui.Request + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { + readDone <- err + return + } + select { + case requests <- request: + case <-childCtx.Done(): + return + } + } + readDone <- scanner.Err() + }() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-readDone: + if err == nil || err == io.EOF { + return nil + } + return err + case update := <-updates: + if err := encoder.Encode(update); err != nil { + return err + } + case text := <-stream: + if text != "" { + if err := encoder.Encode(speechhistoryui.Message{Append: text}); err != nil { + return err + } + } + case request := <-requests: + switch request.Action { + case "copy": + w.announce("Copied to clipboard.") + case "empty": + w.announce("No speech history item selected.") + case "remove": + if request.Selection == "" { + w.announce("No speech history item selected.") + break + } + if w.history.remove(request.Selection) { + w.announce("Removed from history.") + } else { + w.announce("Item not found in history.") + } + if err := encoder.Encode(speechhistoryui.Message{Items: append([]string{}, w.history.snapshot()...), Selected: 0}); err != nil { + return err + } + } + } + } +} diff --git a/internal/app/speech_interrupt.go b/internal/app/speech_interrupt.go new file mode 100644 index 0000000..ed916ff --- /dev/null +++ b/internal/app/speech_interrupt.go @@ -0,0 +1,208 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// Interrupt drops retained requests and wakes the owned cancellation worker. +// It must never perform backend I/O in the caller's event loop. +func (worker *speechWorker) Interrupt() { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if worker.confirmationID != 0 || worker.has_uninterruptible_locked() { + return + } + worker.interruptLocked() +} + +// The preferences worker changes speech availability after the backend has +// accepted a new configuration. Disabling also retires pending and active +// utterances before the new setting becomes visible to event producers. +func (worker *speechWorker) SetEnabled(enabled bool) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if worker.enabled == enabled { + return + } + worker.enabled = enabled + if !enabled { + worker.startupUntil = time.Time{} + worker.interruptLocked() + } +} + +func (worker *speechWorker) interruptLocked() { + if worker.uninterruptibleCancel != nil { + worker.uninterruptibleCancel() + } + worker.presentationDelivery = nil + worker.confirmationID = 0 + if worker.beepCancel != nil { + worker.beepCancel() + } + worker.titleInput.protectRepeats = false + worker.titleWindow = atspi.ObjectID{} + if worker.readAllCancel != nil { + worker.readAllCancel() + worker.readAllCancel = nil + } + if time.Now().Before(worker.startupUntil) { + return + } + if !worker.running { + return + } + completeSpeechRequests(worker.pending, context.Canceled) + worker.pending = nil + worker.generation++ + worker.interrupting = true + + select { + case worker.interrupt <- struct{}{}: + default: + } +} + +func (worker *speechWorker) BeginReadAll(ctx context.Context, timestamp uint32) context.Context { + worker.mutex.Lock() + defer worker.mutex.Unlock() + worker.startupUntil = time.Time{} + if !worker.has_uninterruptible_locked() { + worker.interruptLocked() + } else if worker.readAllCancel != nil { + worker.readAllCancel() + } + readContext, cancel := context.WithCancel(ctx) + worker.readAllCancel = cancel + worker.readAllTimestamp = timestamp + return readContext +} + +func (worker *speechWorker) InterruptForInput(event input.Event) bool { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if event.Timestamp != 0 && worker.commandTimestamp != 0 && int32(event.Timestamp-worker.commandTimestamp) <= 0 { + return false + } + // XRecord and command grabs arrive on separate goroutines. A delayed + // copy of the initiating chord must not cancel the reading it just began. + if worker.readAllCancel != nil && event.Timestamp != 0 && worker.readAllTimestamp != 0 && int32(event.Timestamp-worker.readAllTimestamp) <= 0 { + return false + } + if worker.readAllCancel == nil && worker.titleInput.preserves(event) { + worker.logger.Debug("preserved title speech during held-key repeat", "input_timestamp", event.Timestamp, + "input_keycode", event.Keycode) + return false + } + if event.Timestamp != 0 { + worker.lastInputTimestamp = event.Timestamp + } + if worker.has_uninterruptible_locked() { + return false + } + worker.startupUntil = time.Time{} + worker.interruptLocked() + return true +} + +// ProtectCommand ignores delayed copies of input which already dispatched a +// command. A newly dispatched command also interrupts confirmation speech, +// even when its XRecord input has not arrived yet. +func (worker *speechWorker) ProtectCommand(timestamp uint32) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if timestamp != 0 && worker.commandTimestamp != 0 && int32(timestamp-worker.commandTimestamp) <= 0 { + return + } + if timestamp != 0 { + worker.commandTimestamp = timestamp + } + if worker.confirmationID != 0 && !worker.has_uninterruptible_locked() { + worker.interruptLocked() + } +} + +// Startup announcements need a brief window in which automatic focus events +// append instead of replacing them. A real key press ends that protection. +func (worker *speechWorker) EnqueueStartup(request speechRequest) { + worker.mutex.Lock() + worker.startupUntil = time.Now().Add(3 * time.Second) + worker.mutex.Unlock() + worker.Enqueue(request) +} + +func (worker *speechWorker) InterruptForUser() { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if worker.has_uninterruptible_locked() { + return + } + worker.startupUntil = time.Time{} + worker.interruptLocked() +} + +func (worker *speechWorker) cancelActiveSpeech(ctx context.Context) { + defer close(worker.cancelDone) + for { + select { + case <-ctx.Done(): + return + case <-worker.interrupt: + } + worker.mutex.Lock() + select { + case <-worker.interrupt: + default: + } + generation := worker.generation + activeDone := worker.activeDone + worker.mutex.Unlock() + worker.cancelSpeech(ctx) + if activeDone != nil { + select { + case <-ctx.Done(): + return + case <-activeDone: + } + // SPEAK may have been accepted after the first CANCEL. Keep new + // requests behind the barrier until that in-flight call is settled. + worker.cancelSpeech(ctx) + } + worker.mutex.Lock() + if generation == worker.generation { + worker.interrupting = false + } + worker.mutex.Unlock() + select { + case worker.wake <- struct{}{}: + default: + } + } +} + +func (worker *speechWorker) cancelSpeech(ctx context.Context) { + if err := worker.speaker.Cancel(ctx); err != nil && ctx.Err() == nil { + worker.logger.Warn("interrupt speech", "error", err) + } +} diff --git a/internal/app/speech_mode.go b/internal/app/speech_mode.go new file mode 100644 index 0000000..39e6268 --- /dev/null +++ b/internal/app/speech_mode.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "time" +) + +// The zero value preserves normal startup and the saved voice configuration. +// Modes last for this run only; they never alter enableSpeech or profiles. +type speechMode uint8 + +const ( + speechModeSpeech speechMode = iota + speechModeBeeps + speechModeOff +) + +func (mode speechMode) announcement() string { + switch mode { + case speechModeBeeps: + return "Speech Mode Beeps" + case speechModeOff: + return "Speech off" + default: + return "Speech Mode Speech" + } +} + +func (worker *speechWorker) cycle_speech_mode() speechMode { + worker.mutex.Lock() + defer worker.mutex.Unlock() + worker.mode = (worker.mode + 1) % 3 + worker.startupUntil = time.Time{} + worker.interruptLocked() + worker.logger.Debug("speech mode changed", "mode", worker.mode.announcement()) + return worker.mode +} + +// Capture mode and beep cancellation together so a concurrent mode change or +// key press cannot leave a beep running behind its interruption barrier. +func (worker *speechWorker) deliver_mode(ctx context.Context, request speechRequest) (bool, error) { + worker.mutex.Lock() + mode := worker.mode + if request.ForceSpeech || mode == speechModeSpeech { + worker.mutex.Unlock() + return false, nil + } + if mode == speechModeOff { + worker.mutex.Unlock() + worker.log_mode_delivery(ctx, request, mode) + return true, nil + } + beepContext, cancelBeep := context.WithCancel(ctx) + worker.beepCancel = cancelBeep + worker.mutex.Unlock() + worker.log_mode_delivery(ctx, request, mode) + defer func() { + cancelBeep() + worker.mutex.Lock() + worker.beepCancel = nil + worker.mutex.Unlock() + }() + if worker.beep == nil { + return true, errors.New("speech beep backend unavailable") + } + return true, worker.beep(beepContext) +} + +func (worker *speechWorker) log_mode_delivery(ctx context.Context, request speechRequest, mode speechMode) { + // Say All depends on synth completion. In beeps/off, do not run those + // callbacks and race the reading cursor to the end of the document. + text := request.Text + if len(request.ReadAll) != 0 { + text = request.ReadAll[0].Text + } + if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" { + worker.history.add(text, request.Role) + } + if request.Role == "password text" { + text = "[REDACTED]" + } + worker.logger.DebugContext(ctx, "speech replaced by mode", "mode", mode.announcement(), + "cause", request.Cause, "role", request.Role, "utterance", text) +} diff --git a/internal/app/speech_mode_test.go b/internal/app/speech_mode_test.go new file mode 100644 index 0000000..3044542 --- /dev/null +++ b/internal/app/speech_mode_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +type modeTestSpeaker struct{ spoken chan string } + +func (*modeTestSpeaker) Connect(context.Context) error { return nil } +func (*modeTestSpeaker) Close(context.Context) error { return nil } +func (*modeTestSpeaker) Cancel(context.Context) error { return nil } +func (speaker *modeTestSpeaker) Speak(_ context.Context, text string) error { + speaker.spoken <- text + return nil +} + +type modeTestBraille struct{ messages chan string } + +func (*modeTestBraille) EnqueueFrame(braille.Frame) {} +func (display *modeTestBraille) Flash(text string, _ config.BrailleOptions) bool { + display.messages <- text + return true +} + +func TestSpeechModesCoverEveryDeliveryPath(t *testing.T) { + for _, mode := range []speechMode{speechModeBeeps, speechModeOff} { + t.Run(mode.announcement(), func(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 10)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.mode = mode + beeps, began := 0, false + worker.beep = func(context.Context) error { beeps++; return nil } + for _, request := range []speechRequest{ + {Text: "Focused button"}, + {Text: "A", Character: true}, + {Text: "book", Spelling: []string{"b", "o", "o", "k"}, Character: true}, + {Text: "word", CustomEcho: true}, + {Text: "External message", Cause: "remote-speak"}, + {ReadAll: []readAllPart{{Text: "First line", Began: func() { began = true }}}}, + } { + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + } + if len(speaker.spoken) != 0 || speaker.canceled || began { + t.Fatal("silent/beeps mode used synth, canceled the mode announcement, or advanced Say All") + } + wantBeeps := 0 + if mode == speechModeBeeps { + wantBeeps = 6 + } + if beeps != wantBeeps { + t.Fatalf("beeps = %d, want %d", beeps, wantBeeps) + } + }) + } +} + +func TestSpeechModeCommandAnnouncesAndPreservesBraille(t *testing.T) { + speaker := &modeTestSpeaker{spoken: make(chan string, 5)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + beeps := make(chan struct{}, 5) + worker.beep = func(context.Context) error { beeps <- struct{}{}; return nil } + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + settings := config.DefaultSettings() + display := &modeTestBraille{messages: make(chan string, 10)} + live := &liveSettings{} + live.Store(settings) + output := outputQueue{speech: worker, braille: display, live: live} + commands := &fakeCommands{commands: make(chan input.CommandEvent)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- (commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, + focusGate: &focusGate{}, focusChanges: make(chan xorg.FocusState), + speechMessages: worker, speechOutput: output}).run(ctx) + }() + for index, want := range []string{"Speech Mode Beeps", "Speech off", "Speech Mode Speech"} { + commands.commands <- input.CommandEvent{Command: input.CycleSpeechMode} + select { + case got := <-speaker.spoken: + if got != want { + t.Fatalf("announcement = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("mode announcement missing") + } + select { + case got := <-display.messages: + if got != want { + t.Fatalf("Braille = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatal("Braille mode announcement missing") + } + result := make(chan error, 1) + output.Enqueue(speechRequest{Text: "Ordinary message", Role: "system", Append: true, result: result}) + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("ordinary message did not complete") + } + if got := <-display.messages; got != "Ordinary message" { + t.Fatalf("ordinary Braille = %q", got) + } + switch index { + case 0: + if len(beeps) != 1 { + t.Fatalf("beeps = %d, want 1", len(beeps)) + } + case 1: + if len(beeps) != 1 || len(speaker.spoken) != 0 { + t.Fatal("off mode emitted output") + } + case 2: + if got := <-speaker.spoken; got != "Ordinary message" { + t.Fatalf("speech did not resume: %q", got) + } + } + } + cancel() + <-done +} + +func TestSpeechModeSwitchCancelsActiveBeepAndDropsPendingSpeech(t *testing.T) { + speaker := &modeTestSpeaker{spoken: make(chan string, 2)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.mode = speechModeBeeps + started := make(chan struct{}) + worker.beep = func(ctx context.Context) error { close(started); <-ctx.Done(); return ctx.Err() } + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- worker.EnqueueAndWait(ctx, speechRequest{Text: "Active"}) }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("beep did not start") + } + worker.Enqueue(speechRequest{Text: "Queued"}) + if got := worker.cycle_speech_mode(); got != speechModeOff { + t.Fatalf("mode = %v", got) + } + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("beep result = %v", err) + } + case <-ctx.Done(): + t.Fatal("mode switch did not cancel beep") + } + if err := worker.EnqueueAndWait(ctx, speechRequest{Text: "Speech off", ForceSpeech: true}); err != nil { + t.Fatal(err) + } + if got := <-speaker.spoken; got != "Speech off" { + t.Fatalf("retained stale speech: %q", got) + } +} diff --git a/internal/app/speech_presentation.go b/internal/app/speech_presentation.go new file mode 100644 index 0000000..0719f82 --- /dev/null +++ b/internal/app/speech_presentation.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import "context" + +// revokePresentation drops only the retiring owner's pending speech. If that +// owner has reached delivery, cancel it through the existing backend barrier +// while preserving newer core requests behind the barrier. The next delivered +// request owns backend speech, independently of what is still in the queue. +func (worker *speechWorker) revokePresentation(owner context.Context) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + retained := worker.pending[:0] + for _, request := range worker.pending { + if request.presentationGeneration != nil && request.context == owner { + completeSpeechRequest(request, context.Canceled) + } else { + retained = append(retained, request) + } + } + clear(worker.pending[len(retained):]) + worker.pending = retained + if worker.presentationDelivery != owner || !worker.running { + return + } + worker.presentationDelivery = nil + if worker.beepCancel != nil { + worker.beepCancel() + } + worker.generation++ + worker.interrupting = true + select { + case worker.interrupt <- struct{}{}: + default: + } +} diff --git a/internal/app/speech_settings.go b/internal/app/speech_settings.go new file mode 100644 index 0000000..268d28d --- /dev/null +++ b/internal/app/speech_settings.go @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "math" + "sort" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +// Use the settings worker's bounded mailbox, keeping discovery, disk writes, +// and speech connection replacement off the input and focus paths. +func (worker *preferencesWorker) SpeechCommand(command input.Command) { + worker.TrySpeechCommand(command) +} + +func (worker *preferencesWorker) TrySpeechCommand(command input.Command) bool { + select { + case worker.speechCommands <- command: + return true + default: + return false + } +} + +func (worker *preferencesWorker) announceSpeechSetting(message string) { + if worker.announce != nil { + worker.announce(message) + } +} + +func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, command input.Command) { + if command == input.CycleProgressBarOutput { + worker.cycle_progress_output() + return + } + ctx, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + if command == input.SaveSpeechSettings { + store, err := config.NewStore(worker.settings.Path) + if err == nil { + if worker.activeApp != "" { + err = store.SaveForApplication(worker.activeApp, worker.settings) + } else { + err = store.Save(worker.settings) + } + } + if err != nil { + worker.logger.Warn("save speech settings", "error", err) + worker.announceSpeechSetting("Speech settings could not be saved") + } else { + worker.announceSpeechSetting("Speech settings saved") + } + return + } + switch command { + case input.SelectPreviousSpeechSetting: + worker.speechSetting = max(0, worker.speechSetting-1) + case input.SelectNextSpeechSetting: + worker.speechSetting = min(4, worker.speechSetting+1) + } + next := worker.settings + adjust := command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting + delta := 1 + if command == input.DecreaseSpeechSetting { + delta = -1 + } + message := "" + switch worker.speechSetting { + case 0, 1, 2: + names := []string{"Rate", "Pitch", "Volume"} + fields := []**float64{&next.DefaultVoice.Rate, &next.DefaultVoice.AveragePitch, &next.DefaultVoice.Gain} + defaults := []float64{50, 5, 9} + limits := []float64{99, 9, 9} + steps := []float64{1, 0.1, 0.1} + field := fields[worker.speechSetting] + value := defaults[worker.speechSetting] + if *field != nil { + value = **field + } + if adjust { + value = math.Round((value+float64(delta)*steps[worker.speechSetting])*10) / 10 + value = math.Max(0, math.Min(limits[worker.speechSetting], value)) + *field = &value + } + message = fmt.Sprintf("%s %g", names[worker.speechSetting], value) + if *field == nil { + message = names[worker.speechSetting] + " server default" + } + case 3, 4: + if next.SpeechServerFactory == "hardwarefactory" { + if worker.speechSetting == 4 { + worker.announceSpeechSetting("Hardware speech has no selectable voices") + return + } + modules := []string{"litetalk", "doubletalk", "tripletalk", "dectalk"} + if adjust { + for index, module := range modules { + if module == next.SpeechOutputModule { + next.SpeechOutputModule = modules[max(0, min(len(modules)-1, index+delta))] + break + } + } + } + message = "Hardware synthesizer " + next.SpeechOutputModule + break + } + if worker.catalog == nil { + catalog, err := worker.discover(ctx, worker.socket) + if err != nil { + worker.announceSpeechSetting("Speech settings unavailable") + return + } + worker.catalog = catalog + } + if worker.speechSetting == 3 { + modules := []string{} + for module := range worker.catalog { + if module != "" { + modules = append(modules, module) + } + } + sort.Strings(modules) + if len(modules) == 0 { + worker.announceSpeechSetting("No speech-dispatcher modules available") + return + } + if adjust { + index := 0 + for i, module := range modules { + if module == next.SpeechOutputModule { + index = i + break + } + } + // An unspecified module is a server default, not a known list position. + if next.SpeechOutputModule != "" { + index = max(0, min(len(modules)-1, index+delta)) + } + next.SpeechOutputModule = modules[index] + next.DefaultVoice.Family = nil + if voices := quickSpeechVoices(next.SpeechOutputModule, worker.catalog[next.SpeechOutputModule]); len(voices) > 0 { + next.DefaultVoice.Family = &config.VoiceFamily{Name: voices[0].Name, Lang: voices[0].Language, Variant: voices[0].Variant} + } + } + name := next.SpeechOutputModule + if name == "" { + name = "server default" + } + message = "Speech-dispatcher module " + name + } else { + voices := quickSpeechVoices(next.SpeechOutputModule, worker.catalog[next.SpeechOutputModule]) + if len(voices) == 0 { + worker.announceSpeechSetting("No speech-dispatcher voices available") + return + } + name := "server default" + if family := next.DefaultVoice.Family; family != nil && family.Name != "" { + name = family.Name + } + if adjust { + index := 0 + for i, voice := range voices { + if voice.Name == name { + index = max(0, min(len(voices)-1, i+delta)) + break + } + } + voice := voices[index] + next.DefaultVoice.Family = &config.VoiceFamily{Name: voice.Name, Lang: voice.Language, Variant: voice.Variant} + name = voice.Name + } + message = "Speech-dispatcher voice " + name + } + } + if adjust { + err := config.Validate(next) + if err == nil { + err = worker.updateSpeech(ctx, next) + } + if err != nil { + worker.logger.Warn("adjust speech setting", "error", err) + worker.catalog = nil + worker.announceSpeechSetting("Speech settings were not applied") + return + } + worker.settings = next + if worker.live != nil { + worker.live.Store(next) + } + } + worker.announceSpeechSetting(message) +} + +// Keep eSpeak NG's language/variant cross product manageable in quick settings. +// Prefer an exact dialect, then a language-only voice if that dialect is absent. +// Unknown languages retain the catalog so the user can still select a voice. +func quickSpeechVoices(module string, voices []speech.Voice) []speech.Voice { + if !strings.Contains(strings.ToLower(module), "espeak-ng") { + return voices + } + return speech.LocaleVoices(voices) +} diff --git a/internal/app/speech_settings_test.go b/internal/app/speech_settings_test.go new file mode 100644 index 0000000..aa96a52 --- /dev/null +++ b/internal/app/speech_settings_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "errors" + "log/slog" + "path/filepath" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func TestSpeechShortcutsApplySaveAndFailure(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + rate := 50.0 + settings.DefaultVoice.Rate = &rate + var applied speech.Settings + var updateError error + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { + if updateError != nil { + return updateError + } + applied = next + return nil + })) + worker.live = &liveSettings{} + worker.live.Store(settings) + message := "" + worker.announce = func(text string) { message = text } + command := func(c input.Command) { t.Helper(); worker.handleSpeechCommand(context.Background(), c) } + command(input.IncreaseSpeechSetting) + if message != "Rate 51" || applied.Rate == nil || *applied.Rate != 4 || *worker.live.Load().DefaultVoice.Rate != 51 || rate != 50 { + t.Fatalf("rate: message %q, applied %+v, original %g", message, applied, rate) + } + updateError = errors.New("backend unavailable") + command(input.IncreaseSpeechSetting) + if message != "Speech settings were not applied" || *worker.live.Load().DefaultVoice.Rate != 51 { + t.Fatal("failed update changed session") + } + updateError = nil + worker.catalog = speech.Catalog{"": {{Name: "default", Language: "en"}}, "alpha": {{Name: "one", Language: "en"}, {Name: "two", Language: "en"}}, "beta": {{Name: "three", Language: "en"}}} + for _, want := range []string{"Pitch server default", "Volume server default", "Speech-dispatcher module server default"} { + command(input.SelectNextSpeechSetting) + if message != want { + t.Fatalf("got %q, want %q", message, want) + } + } + command(input.IncreaseSpeechSetting) + if applied.OutputModule != "alpha" || applied.SynthesisVoice != "one" { + t.Fatalf("module: %+v", applied) + } + command(input.SelectNextSpeechSetting) + command(input.IncreaseSpeechSetting) + if applied.SynthesisVoice != "two" { + t.Fatalf("voice: %+v", applied) + } + command(input.SelectNextSpeechSetting) + if worker.speechSetting != 4 { + t.Fatal("selector wrapped") + } + command(input.SaveSpeechSettings) + if message != "Speech settings saved" { + t.Fatal(message) + } + store, err := config.NewStore(settings.Path) + if err != nil { + t.Fatal(err) + } + saved, err := store.Load() + if err != nil { + t.Fatal(err) + } + if saved.SpeechOutputModule != "alpha" || saved.DefaultVoice.Family.Name != "two" || *saved.DefaultVoice.Rate != 51 { + t.Fatalf("saved settings did not retain speech choices: %+v", saved.DefaultVoice) + } + worker.settings.Path = t.TempDir() + command(input.SaveSpeechSettings) + if message != "Speech settings could not be saved" { + t.Fatal(message) + } + // A backend failure must not affect later selection or saving. + command(input.SelectPreviousSpeechSetting) + if worker.speechSetting != 3 { + t.Fatal("selector did not recover") + } +} + +func TestSpeechShortcutNumericBounds(t *testing.T) { + for index, maximum := range []float64{99, 9, 9} { + settings := config.DefaultSettings() + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) + worker.speechSetting = index + fields := []**float64{&worker.settings.DefaultVoice.Rate, &worker.settings.DefaultVoice.AveragePitch, &worker.settings.DefaultVoice.Gain} + *fields[index] = &maximum + worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) + if **fields[index] != maximum { + t.Fatal("upper bound exceeded") + } + zero := 0.0 + *fields[index] = &zero + worker.handleSpeechCommand(context.Background(), input.DecreaseSpeechSetting) + if **fields[index] != 0 { + t.Fatal("lower bound exceeded") + } + worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) + want := 0.1 + if index == 0 { + want = 1 + } + if **fields[index] != want { + t.Fatalf("step %g, want %g", **fields[index], want) + } + } +} + +func TestSpeechShortcutUnavailableDiscovery(t *testing.T) { + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) + worker.speechSetting = 3 + worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, errors.New("offline") } + message := "" + worker.announce = func(text string) { message = text } + worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) + if message != "Speech settings unavailable" || worker.settings.SpeechOutputModule != "" { + t.Fatal(message) + } +} + +func TestQuickSpeechESpeakLocale(t *testing.T) { + voices := []speech.Voice{ + {Name: "de+max", Language: "de"}, + {Name: "en-us+max", Language: "en-us"}, + {Name: "en-gb", Language: "en-gb"}, + {Name: "en-us", Language: "en-us", Variant: "male"}, + {Name: "en", Language: "en"}, + {Name: "en-us+Storm", Language: "en-us"}, + } + for _, tc := range []struct { + name, all, messages, lang, module string + want []string + }{ + {"exact", "en_US.UTF-8", "de_DE", "en_GB", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, + {"messages", "", "en_US@custom", "de_DE", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, + {"lang", "", "", "en-GB.UTF-8", "espeak-ng", []string{"en-gb"}}, + {"language fallback", "en_AU", "", "", "espeak-ng", []string{"en"}}, + {"C fallback", "C.UTF-8", "", "", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, + {"missing locale", "", "", "", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, + {"unavailable language", "zz_ZZ", "", "", "espeak-ng", []string{"de+max", "en-us+max", "en-gb", "en-us", "en", "en-us+Storm"}}, + {"other module", "en_US", "", "", "other", []string{"de+max", "en-us+max", "en-gb", "en-us", "en", "en-us+Storm"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LC_ALL", tc.all) + t.Setenv("LC_MESSAGES", tc.messages) + t.Setenv("LANG", tc.lang) + got := quickSpeechVoices(tc.module, voices) + if len(got) != len(tc.want) { + t.Fatalf("voices = %v, want %v", got, tc.want) + } + for i, want := range tc.want { + if got[i].Name != want { + t.Fatalf("voices = %v, want %v", got, tc.want) + } + } + if voices[0].Name != "de+max" || voices[3].Name != "en-us" { + t.Fatal("catalog mutated") + } + }) + } +} + +func TestSpeechShortcutESpeakDefaultAndVariants(t *testing.T) { + t.Setenv("LC_ALL", "en_US.UTF-8") + var applied speech.Settings + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { + applied = next + return nil + })) + worker.catalog = speech.Catalog{"espeak-ng": { + {Name: "de", Language: "de"}, + {Name: "en-us+max", Language: "en-us"}, + {Name: "en-us", Language: "en-us"}, + {Name: "fr", Language: "fr"}, + }} + worker.speechSetting = 3 + worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) + if applied.OutputModule != "espeak-ng" || applied.SynthesisVoice != "en-us" || applied.Language != "en-us" { + t.Fatalf("module switch: %+v", applied) + } + worker.handleSpeechCommand(context.Background(), input.SelectNextSpeechSetting) + for _, tc := range []struct { + command input.Command + want string + }{ + {input.IncreaseSpeechSetting, "en-us+max"}, + {input.IncreaseSpeechSetting, "en-us+max"}, + {input.DecreaseSpeechSetting, "en-us"}, + {input.DecreaseSpeechSetting, "en-us"}, + } { + worker.handleSpeechCommand(context.Background(), tc.command) + if applied.SynthesisVoice != tc.want { + t.Fatalf("voice = %q, want %q", applied.SynthesisVoice, tc.want) + } + } +} diff --git a/internal/app/speech_title_input.go b/internal/app/speech_title_input.go new file mode 100644 index 0000000..e721beb --- /dev/null +++ b/internal/app/speech_title_input.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import "git.stormux.org/storm/cthulhu/internal/input" + +// titleInputPolicy follows the latest accepted presentation, including the +// period after SPEAK returns while the synthesizer is still speaking it. +// Only automatic titles ignore repeats. Normal reading keeps its input policy. +// The speech worker's mutex owns this state. +type titleInputPolicy struct { + protectRepeats bool +} + +func (policy *titleInputPolicy) observe(request speechRequest) { + policy.protectRepeats = request.Cause == "window-title" && request.Text != "" +} + +func (policy *titleInputPolicy) preserves(event input.Event) bool { + return policy.protectRepeats && event.Repeat && event.Pressed && + event.Name != "Control_L" && event.Name != "Control_R" && event.Name != "Escape" +} diff --git a/internal/app/speech_title_input_test.go b/internal/app/speech_title_input_test.go new file mode 100644 index 0000000..98d6e92 --- /dev/null +++ b/internal/app/speech_title_input_test.go @@ -0,0 +1,79 @@ +// 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" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestAutomaticTitleSurvivesMovementRepeatsBeforeAndAfterDelivery(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + worker.running = true + worker.Enqueue(speechRequest{Text: "Game status", Cause: "window-title", Append: true}) + repeat := input.Event{Name: "Up", Pressed: true, Repeat: true, Timestamp: 100} + if worker.InterruptForInput(repeat) || len(worker.pending) != 1 { + t.Fatal("repeat discarded queued title") + } + request, ok := worker.next() + if !ok { + t.Fatal("title missing from queue") + } + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + if text := <-speaker.spoken; text != "Game status" { + t.Fatalf("speech = %q", text) + } + if worker.InterruptForInput(repeat) || worker.generation != 0 { + t.Fatal("repeat interrupted title after backend accepted it") + } + if !worker.InterruptForInput(input.Event{Name: "Up", Pressed: true, Timestamp: 200}) { + t.Fatal("fresh movement key did not interrupt") + } +} + +func TestAutomaticTitleStillStopsForExplicitInput(t *testing.T) { + for _, event := range []input.Event{ + {Name: "Left", Pressed: true}, + {Name: "Control_L", Pressed: true, Repeat: true}, + {Name: "Control_R", Pressed: true, Repeat: true}, + {Name: "Escape", Pressed: true, Repeat: true}, + } { + t.Run(event.Name, func(t *testing.T) { + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true + worker.Enqueue(speechRequest{Text: "Game status", Cause: "window-title", Append: true}) + if !worker.InterruptForInput(event) || len(worker.pending) != 0 || worker.generation != 1 { + t.Fatal("explicit input did not stop title speech") + } + }) + } +} + +func TestTitleProtectionDoesNotChangeOrdinaryReading(t *testing.T) { + for _, cause := range []string{"focus", "flat-review", "current-time", "key-echo", "read-all"} { + t.Run(cause, func(t *testing.T) { + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true + worker.Enqueue(speechRequest{Text: "Game status", Cause: "window-title", Append: true}) + worker.Enqueue(speechRequest{Text: "Reading", Cause: cause}) + if !worker.InterruptForInput(input.Event{Name: "Up", Pressed: true, Repeat: true}) || len(worker.pending) != 0 { + t.Fatal("title protection leaked into ordinary reading") + } + }) + } + worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + worker.running = true + ctx := worker.BeginReadAll(context.Background(), 100) + worker.Enqueue(speechRequest{Text: "Game status", Cause: "window-title", Append: true}) + if !worker.InterruptForInput(input.Event{Name: "Up", Pressed: true, Repeat: true, Timestamp: 101}) || ctx.Err() != context.Canceled { + t.Fatal("appended title prevented held key from stopping Say All") + } +} diff --git a/internal/app/speech_uninterruptible.go b/internal/app/speech_uninterruptible.go new file mode 100644 index 0000000..a2a9195 --- /dev/null +++ b/internal/app/speech_uninterruptible.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "time" +) + +// Bound protection even if the backend never reports completion. The deadline +// starts at admission, covering time spent in the bounded pending queue too. +const uninterruptibleSpeechTimeout = 30 * time.Second + +func (worker *speechWorker) has_uninterruptible_locked() bool { + if worker.uninterruptibleContext != nil && worker.uninterruptibleContext.Err() == nil { + return true + } + for _, request := range worker.pending { + if request.Uninterruptible && request.context != nil && request.context.Err() == nil { + return true + } + } + return false +} + +// Preserve unrelated queued speech, just as ordinary presentation revocation +// does. The existing cancellation barrier prevents CANCEL overtaking it. +func (worker *speechWorker) stop_uninterruptible() { + worker.mutex.Lock() + defer worker.mutex.Unlock() + retained := worker.pending[:0] + for _, request := range worker.pending { + if request.Uninterruptible { + completeSpeechRequest(request, context.Canceled) + } else { + retained = append(retained, request) + } + } + clear(worker.pending[len(retained):]) + worker.pending = retained + if worker.uninterruptibleCancel == nil || !worker.running { + return + } + worker.uninterruptibleCancel() + worker.presentationDelivery = nil + if worker.beepCancel != nil { + worker.beepCancel() + } + worker.generation++ + worker.interrupting = true + select { + case worker.interrupt <- struct{}{}: + default: + } +} diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go new file mode 100644 index 0000000..8691271 --- /dev/null +++ b/internal/app/speech_worker.go @@ -0,0 +1,414 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// speechQueueCapacity bounds work retained while the speech backend is slow. +// Requests remain FIFO. On overflow, the oldest pending request is discarded +// so that current focus is favored, and the loss is reported without content. +const speechQueueCapacity = 64 + +type speechRequest struct { + presentationGeneration *uint64 // Checked under the speech owner mutex. + presentationRevision *uint64 + + Window atspi.ObjectID + IntroducesWindow bool + ReadAll []readAllPart + CustomEcho bool + Character bool + VoiceType string + Line *speechLine + Context []atspi.Snapshot + Spelling []string // One cancellable queue item, with separate letter utterances. + SpellingVoices []string + Sound []sound.Cue + Chunks []string // Presentation pause boundaries; one cancellable queue item. + FallbackChunks []string + Text string + FallbackText string + Role string + Cause string + Append bool + ClipboardTimestamp uint32 + BypassFocusGate bool + ExcludeFromHistory bool + ForceSpeech bool // Only the speech-mode announcement bypasses the mode. + Confirmation bool // Wait for speech completion before automatic output. + Uninterruptible bool // Accepted tentacle message; ordinary input cannot cancel it. + confirmationID uint64 + deliveryGeneration uint64 + context context.Context + cancel context.CancelFunc + result chan error +} + +type readAllPart struct { + Text string + VoiceType string + Line *speechLine + Context []atspi.Snapshot + Began func() +} + +// speechWorker owns the pending queue under mutex. Delivery and cancellation +// have separate goroutines and joins; activeDone and generation form their +// barrier so cancellation cannot overtake newly accepted output. +type speechWorker struct { + logger *slog.Logger + speaker Speaker + history *speechHistory + beep func(context.Context) error + textTransform func(string) string + reading func() config.ReadingSettings + verbosity func() int + indentation indentationHistory // Owned by the serialized delivery goroutine. + speechContext []atspi.Snapshot // Same owner; replaced on each known navigation context. + indentationCue func(sound.Cue) + + mutex sync.Mutex + enabled bool + mode speechMode + beepCancel context.CancelFunc + pending []speechRequest + wake chan struct{} + interrupt chan struct{} + runDone chan struct{} + cancelDone chan struct{} + cancel context.CancelFunc + secure bool + started bool + running bool + workerContext context.Context + overflow uint64 + reported uint64 + generation uint64 + interrupting bool + activeDone chan struct{} + startupUntil time.Time + readAllCancel context.CancelFunc + readAllTimestamp uint32 + commandTimestamp uint32 + lastInputTimestamp uint32 + titleWindow atspi.ObjectID + titleInput titleInputPolicy + confirmationID uint64 // Owned under mutex; reset on completion or user interruption. + confirmationSerial uint64 + + presentationDelivery context.Context // Replaced when the next request reaches delivery. + presentationRevision uint64 + uninterruptibleContext context.Context + uninterruptibleCancel context.CancelFunc +} + +func newSpeechWorker(logger *slog.Logger, speaker Speaker) *speechWorker { + return &speechWorker{ + logger: logger, + speaker: speaker, + enabled: true, + wake: make(chan struct{}, 1), + interrupt: make(chan struct{}, 1), + } +} + +func (worker *speechWorker) Start(ctx context.Context) error { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if worker.started { + return errors.New("speech worker already started") + } + workerContext, cancel := context.WithCancel(ctx) + worker.workerContext = workerContext + worker.cancel = cancel + worker.started = true + worker.running = true + worker.runDone = make(chan struct{}) + worker.cancelDone = make(chan struct{}) + go worker.run(workerContext) + go worker.cancelActiveSpeech(workerContext) + return nil +} + +func (worker *speechWorker) Enqueue(request speechRequest) { + worker.mutex.Lock() + if request.presentationGeneration != nil && (*request.presentationGeneration != worker.generation || + request.presentationRevision == nil || *request.presentationRevision != worker.presentationRevision) { + worker.mutex.Unlock() + completeSpeechRequest(request, context.Canceled) + return + } + if !worker.enabled { + worker.mutex.Unlock() + completeSpeechRequest(request, context.Canceled) + return + } + // Equal millisecond timestamps can belong to the copy key and its result. + if request.ClipboardTimestamp != 0 && worker.lastInputTimestamp != 0 && int32(request.ClipboardTimestamp-worker.lastInputTimestamp) < 0 { + worker.mutex.Unlock() + completeSpeechRequest(request, context.Canceled) + return + } + if worker.secure && !strings.HasPrefix(request.Cause, "remote-") { + worker.mutex.Unlock() + completeSpeechRequest(request, context.Canceled) + return + } + if worker.readAllCancel != nil && request.Cause != "read-all" && !request.Append { + worker.readAllCancel() + worker.readAllCancel = nil + } + if request.context != nil && request.context.Err() != nil { + worker.mutex.Unlock() + completeSpeechRequest(request, request.context.Err()) + return + } + if !worker.running { + worker.mutex.Unlock() + completeSpeechRequest(request, errors.New("speech worker is not running")) + return + } + if request.Confirmation { + worker.confirmationSerial++ + request.confirmationID = worker.confirmationSerial + worker.confirmationID = request.confirmationID + } else if worker.confirmationID != 0 { + request.Append = true + } + if worker.has_uninterruptible_locked() { + request.Append = true + } + // Pair a title with the next announcement from its window. User input + // and explicit interruption retire this pairing; another window starts + // normally. This queues the continuation without protecting the title + // against navigation or estimating how long it will take to speak. + if worker.titleWindow.Valid() && request.Window == worker.titleWindow && !request.IntroducesWindow { + request.Append = true + } + worker.titleWindow = atspi.ObjectID{} + if request.IntroducesWindow && request.Text != "" { + worker.titleWindow = request.Window + } + if time.Now().Before(worker.startupUntil) && request.Cause != "startup" { + request.Append = true + } + if len(worker.pending) == speechQueueCapacity { + if worker.pending[0].confirmationID == worker.confirmationID { + worker.confirmationID = 0 + } + completeSpeechRequest(worker.pending[0], errors.New("speech request dropped after queue overflow")) + copy(worker.pending, worker.pending[1:]) + worker.pending = worker.pending[:speechQueueCapacity-1] + worker.overflow++ + } + worker.titleInput.observe(request) + worker.presentationRevision++ + worker.pending = append(worker.pending, request) + worker.mutex.Unlock() + + select { + case worker.wake <- struct{}{}: + default: + } +} + +func (worker *speechWorker) EnqueueAndWait(ctx context.Context, request speechRequest) error { + worker.mutex.Lock() + if worker.running { + requestContext, cancelRequest := context.WithCancel(worker.workerContext) + request.context = requestContext + request.cancel = cancelRequest + } + worker.mutex.Unlock() + + request.result = make(chan error, 1) + worker.Enqueue(request) + stopCallerWatch := context.AfterFunc(ctx, func() { + if request.cancel != nil { + request.cancel() + } + }) + select { + case err := <-request.result: + if request.cancel != nil { + request.cancel() + } + stopCallerWatch() + return err + case <-ctx.Done(): + if request.cancel != nil { + request.cancel() + } + stopCallerWatch() + return ctx.Err() + } +} + +func (worker *speechWorker) Stop(ctx context.Context) error { + worker.mutex.Lock() + if !worker.started { + worker.mutex.Unlock() + return nil + } + worker.running = false + worker.confirmationID = 0 + if worker.uninterruptibleCancel != nil { + worker.uninterruptibleCancel() + } + if worker.readAllCancel != nil { + worker.readAllCancel() + worker.readAllCancel = nil + } + completeSpeechRequests(worker.pending, context.Canceled) + worker.pending = nil + cancel := worker.cancel + runDone := worker.runDone + cancelDone := worker.cancelDone + worker.mutex.Unlock() + + cancel() + for _, done := range []chan struct{}{runDone, cancelDone} { + select { + case <-done: + case <-ctx.Done(): + worker.reportOverflow() + return fmt.Errorf("wait for speech worker: %w", ctx.Err()) + } + } + worker.reportOverflow() + return nil +} + +func (worker *speechWorker) run(ctx context.Context) { + defer close(worker.runDone) + defer func() { + worker.mutex.Lock() + worker.pending = nil + worker.running = false + worker.mutex.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return + case <-worker.wake: + } + worker.reportOverflow() + + for { + if ctx.Err() != nil { + return + } + request, ok := worker.next() + if !ok { + break + } + deliveryContext := ctx + if request.context != nil { + deliveryContext = request.context + } + err := worker.deliver(deliveryContext, request) + completeSpeechRequest(request, err) + worker.mutex.Lock() + worker.uninterruptibleContext, worker.uninterruptibleCancel = nil, nil + if request.confirmationID == worker.confirmationID { + worker.confirmationID = 0 + } + close(worker.activeDone) + worker.activeDone = nil + worker.mutex.Unlock() + if err != nil { + if ctx.Err() != nil { + return + } + worker.logger.Error("deliver accessible presentation", "role", request.Role, "error", err) + } + } + } +} + +func completeSpeechRequests(requests []speechRequest, err error) { + for _, request := range requests { + completeSpeechRequest(request, err) + } +} + +func completeSpeechRequest(request speechRequest, err error) { + if request.Uninterruptible && request.cancel != nil { + request.cancel() + } + if request.result == nil { + return + } + select { + case request.result <- err: + default: + } +} + +func (worker *speechWorker) reportOverflow() { + worker.mutex.Lock() + if worker.overflow == worker.reported { + worker.mutex.Unlock() + return + } + dropped := worker.overflow - worker.reported + worker.reported = worker.overflow + total := worker.overflow + worker.mutex.Unlock() + worker.logger.Warn( + "speech queue overflow", + "dropped_requests", dropped, + "total_dropped_requests", total, + ) +} + +func (worker *speechWorker) next() (speechRequest, bool) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if !worker.running || worker.interrupting || len(worker.pending) == 0 { + return speechRequest{}, false + } + worker.activeDone = make(chan struct{}) + request := worker.pending[0] + if request.Uninterruptible { + worker.uninterruptibleContext, worker.uninterruptibleCancel = request.context, request.cancel + } + worker.presentationDelivery = nil + if request.presentationGeneration != nil { + worker.presentationDelivery = request.context + } + request.deliveryGeneration = worker.generation + copy(worker.pending, worker.pending[1:]) + worker.pending[len(worker.pending)-1] = speechRequest{} + worker.pending = worker.pending[:len(worker.pending)-1] + return request, true +} diff --git a/internal/app/speech_worker_test.go b/internal/app/speech_worker_test.go new file mode 100644 index 0000000..8ce891b --- /dev/null +++ b/internal/app/speech_worker_test.go @@ -0,0 +1,412 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/logging" +) + +type blockingSpeaker struct { + started chan struct{} + once sync.Once +} + +func (speaker *blockingSpeaker) Connect(context.Context) error { return nil } +func (speaker *blockingSpeaker) Cancel(context.Context) error { return nil } +func (speaker *blockingSpeaker) Close(context.Context) error { return nil } + +func (speaker *blockingSpeaker) Speak(ctx context.Context, _ string) error { + speaker.once.Do(func() { close(speaker.started) }) + <-ctx.Done() + return ctx.Err() +} + +func TestSpeechWorkerDeliversInOrder(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 2)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + worker.Enqueue(speechRequest{Text: "First", Role: "label"}) + worker.Enqueue(speechRequest{Text: "Second", Role: "button"}) + for _, want := range []string{"First", "Second"} { + select { + case got := <-speaker.spoken: + if got != want { + t.Fatalf("spoken text = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %q", want) + } + } + + if err := worker.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +func TestStartupSpeechSurvivesAutomaticFocusInterruption(t *testing.T) { + speaker := &blockingSpeaker{started: make(chan struct{})} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.EnqueueStartup(speechRequest{Text: "Welcome", Cause: "startup"}) + select { + case <-speaker.started: + case <-time.After(time.Second): + t.Fatal("startup speech did not begin") + } + worker.Interrupt() // Automatic window focus must not cancel the welcome. + worker.Enqueue(speechRequest{Text: "Focused control", Cause: "focus:"}) + worker.mutex.Lock() + retained := len(worker.pending) == 1 && worker.pending[0].Text == "Focused control" && worker.pending[0].Append + worker.mutex.Unlock() + if !retained { + t.Fatal("automatic focus did not append after protected startup speech") + } + worker.InterruptForUser() + worker.mutex.Lock() + cleared := len(worker.pending) == 0 && worker.startupUntil.IsZero() + worker.mutex.Unlock() + if !cleared { + t.Fatal("user key did not end startup protection and clear queued focus") + } +} + +func TestSpeechWorkerEnqueueAndWaitReportsDelivery(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + + delivered := make(chan error, 1) + go func() { + delivered <- worker.EnqueueAndWait(context.Background(), speechRequest{Text: shutdownMessages[0]}) + }() + if got := <-speaker.spoken; got != shutdownMessages[0] { + t.Fatalf("spoken text = %q, want %q", got, shutdownMessages[0]) + } + if err := <-delivered; err != nil { + t.Fatalf("EnqueueAndWait: %v", err) + } +} + +func TestEnqueueAndWaitTimeoutCancelsDelivery(t *testing.T) { + speaker := &blockingSpeaker{started: make(chan struct{})} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + defer worker.Stop(context.Background()) + + requestContext, cancelRequest := context.WithCancel(context.Background()) + defer cancelRequest() + delivered := make(chan error, 1) + go func() { + delivered <- worker.EnqueueAndWait(requestContext, speechRequest{Text: "Blocked"}) + }() + + select { + case <-speaker.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for speech delivery to start") + } + + worker.mutex.Lock() + activeDone := worker.activeDone + worker.mutex.Unlock() + if activeDone == nil { + t.Fatal("active speech request was not started") + } + cancelRequest() + if err := <-delivered; !errors.Is(err, context.Canceled) { + t.Fatalf("EnqueueAndWait error = %v, want canceled", err) + } + select { + case <-activeDone: + case <-time.After(time.Second): + t.Fatal("timed-out speech request was not canceled") + } +} + +func TestSpeechWorkerBoundsQueueAndKeepsNewestRequests(t *testing.T) { + speaker := &blockingSpeaker{started: make(chan struct{})} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + worker.Enqueue(speechRequest{Text: "Blocking"}) + + select { + case <-speaker.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for blocking speech call") + } + + for index := 0; index < speechQueueCapacity+2; index++ { + worker.Enqueue(speechRequest{Text: fmt.Sprintf("Pending %d", index)}) + } + + worker.mutex.Lock() + if got := len(worker.pending); got != speechQueueCapacity { + worker.mutex.Unlock() + t.Fatalf("pending requests = %d, want %d", got, speechQueueCapacity) + } + if got := worker.overflow; got != 2 { + worker.mutex.Unlock() + t.Fatalf("overflow = %d, want 2", got) + } + oldest := worker.pending[0].Text + worker.mutex.Unlock() + if oldest != "Pending 2" { + t.Fatalf("oldest retained request = %q, want %q", oldest, "Pending 2") + } + + stopContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := worker.Stop(stopContext); err != nil { + t.Fatalf("Stop: %v", err) + } + worker.mutex.Lock() + reported := worker.reported + worker.mutex.Unlock() + if reported != 2 { + t.Fatalf("reported overflow = %d, want 2", reported) + } +} + +type speechCall struct { + text string + done chan struct{} +} + +type controlledSpeaker struct { + calls chan speechCall +} + +func (speaker *controlledSpeaker) Connect(context.Context) error { return nil } +func (speaker *controlledSpeaker) Close(context.Context) error { return nil } +func (speaker *controlledSpeaker) Cancel(ctx context.Context) error { + return speaker.call(ctx, "cancel") +} +func (speaker *controlledSpeaker) Speak(ctx context.Context, text string) error { + return speaker.call(ctx, text) +} +func (speaker *controlledSpeaker) call(ctx context.Context, text string) error { + call := speechCall{text: text, done: make(chan struct{})} + select { + case speaker.calls <- call: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-call.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func expectSpeechCall(t *testing.T, speaker *controlledSpeaker, want string) speechCall { + t.Helper() + select { + case call := <-speaker.calls: + if call.text != want { + t.Fatalf("call = %q, want %q", call.text, want) + } + return call + case <-time.After(time.Second): + t.Fatalf("waiting for %q", want) + return speechCall{} + } +} + +func TestInterruptDropsPendingAndOrdersNextSpeech(t *testing.T) { + speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + worker.Enqueue(speechRequest{Text: "Active"}) + close(expectSpeechCall(t, speaker, "cancel").done) + active := expectSpeechCall(t, speaker, "Active") + worker.Enqueue(speechRequest{Text: "Discarded"}) + worker.Interrupt() + interrupt := expectSpeechCall(t, speaker, "cancel") + // Flooding interrupts is bounded and never waits for the backend. + for range 1000 { + worker.Interrupt() + } + worker.Enqueue(speechRequest{Text: "Next"}) + close(interrupt.done) + close(active.done) + // One follow-up cancellation closes the acceptance race; a coalesced + // second interrupt may add another. No stale request may reach Speak. + for { + select { + case call := <-speaker.calls: + if call.text != "cancel" && call.text != "Next" { + t.Fatalf("unexpected %q", call.text) + } + close(call.done) + if call.text == "Next" { + if err := worker.Stop(context.Background()); err != nil { + t.Fatal(err) + } + return + } + case <-time.After(time.Second): + t.Fatal("next speech did not resume") + } + } +} + +type stubbornCancelSpeaker struct { + blockingSpeaker + cancelStarted chan struct{} + release chan struct{} +} + +func (speaker *stubbornCancelSpeaker) Cancel(context.Context) error { + close(speaker.cancelStarted) + <-speaker.release + return nil +} + +func TestStopWaitsForCancellationEvenAfterDeliveryExits(t *testing.T) { + speaker := &stubbornCancelSpeaker{cancelStarted: make(chan struct{}), release: make(chan struct{})} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { close(speaker.release); worker.Stop(context.Background()) }) + worker.Interrupt() + select { + case <-speaker.cancelStarted: + case <-time.After(time.Second): + t.Fatal("cancel did not start") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := worker.Stop(ctx); err == nil { + t.Fatal("Stop reported success with cancellation blocked") + } + <-worker.runDone + if err := worker.Stop(ctx); err == nil { + t.Fatal("repeated Stop forgot cancellation worker") + } +} + +type orderedSpeaker struct { + calls []string +} + +func (speaker *orderedSpeaker) Connect(context.Context) error { return nil } +func (speaker *orderedSpeaker) Close(context.Context) error { return nil } +func (speaker *orderedSpeaker) Cancel(context.Context) error { + speaker.calls = append(speaker.calls, "cancel") + return nil +} +func (speaker *orderedSpeaker) Speak(_ context.Context, text string) error { + speaker.calls = append(speaker.calls, text) + return nil +} + +func TestTerminalOutputAndPromptDoNotCancelEachOther(t *testing.T) { + speaker := &orderedSpeaker{} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + for _, request := range []speechRequest{ + {Text: "Command output", Role: "terminal", Append: true}, + {Text: "Prompt", Role: "terminal", Append: true}, + {Text: "Save", Role: "button"}, + } { + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + } + if got := fmt.Sprint(speaker.calls); got != "[Command output Prompt cancel Save]" { + t.Fatalf("speech order = %s", got) + } +} + +func TestSpeechDeliveryDiagnosticsIncludeUtteranceAndPolicy(t *testing.T) { + var output bytes.Buffer + logger := logging.New(&output, logging.Options{Level: slog.LevelDebug}) + worker := newSpeechWorker(logger, &orderedSpeaker{}) + request := speechRequest{ + Text: "Terminal output with Café and 猫.", + Role: "terminal", + Cause: "object:text-changed:insert", + Append: true, + } + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + logText := output.String() + for _, expected := range []string{ + `"cause":"object:text-changed:insert"`, + `"append":true`, + `"interrupt_previous":false`, + `"utterance":"Terminal output with Café and 猫."`, + } { + if !strings.Contains(logText, expected) { + t.Fatalf("diagnostics %q do not contain %q", logText, expected) + } + } +} + +func TestNormalSpeechDeliveryDiagnosticsOmitUtterance(t *testing.T) { + var output bytes.Buffer + logger := logging.New(&output, logging.Options{Level: slog.LevelInfo}) + worker := newSpeechWorker(logger, &orderedSpeaker{}) + request := speechRequest{Text: "Terminal output", Role: "terminal"} + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), request.Text) { + t.Fatalf("normal diagnostics exposed speech text: %q", output.String()) + } +} + +func TestSpeechDeliveryDiagnosticsProtectPasswordText(t *testing.T) { + var output bytes.Buffer + worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), &orderedSpeaker{}) + request := speechRequest{Text: "generic-secret", Role: "password text"} + if err := worker.deliver(context.Background(), request); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), request.Text) || !strings.Contains(output.String(), `"utterance":"[REDACTED]"`) { + t.Fatalf("protected speech diagnostics: %s", output.String()) + } +} diff --git a/internal/app/startup_sound_test.go b/internal/app/startup_sound_test.go new file mode 100644 index 0000000..ab99040 --- /dev/null +++ b/internal/app/startup_sound_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "reflect" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// Hold the startup playback at the backend while application settings commit. +// No audio device or timing sleep is needed to observe synchronous cancellation. +type startupSoundPlayer struct { + started chan context.Context + release chan struct{} + once sync.Once +} + +func (player *startupSoundPlayer) Play(ctx context.Context, _ []byte, _, _ float64) error { + first := false + player.once.Do(func() { first = true }) + if first { + player.started <- ctx + select { + case <-ctx.Done(): + return ctx.Err() + case <-player.release: + } + } + return nil +} + +func TestApplicationSettingsPreserveStartupSoundUnlessSoundPolicyChanges(t *testing.T) { + for _, test := range []struct { + name string + change func(*config.Settings) + cancel bool + }{ + {name: "same settings", change: func(*config.Settings) {}}, + {name: "title reading override", change: func(settings *config.Settings) { + settings.Reading.AutomaticallyReadTitleChanges = false + }}, + {name: "volume override", cancel: true, change: func(settings *config.Settings) { + settings.SoundVolume = 0.8 + }}, + {name: "disabled sound", cancel: true, change: func(settings *config.Settings) { + settings.EnableSound = false + }}, + {name: "theme override", cancel: true, change: func(settings *config.Settings) { + settings.SoundTheme = "none" + }}, + {name: "sink override", cancel: true, change: func(settings *config.Settings) { + settings.SoundSink = "pulse" + }}, + } { + t.Run(test.name, func(t *testing.T) { + settings := config.DefaultSettings() + logger := slog.New(slog.DiscardHandler) + player := &startupSoundPlayer{started: make(chan context.Context, 1), release: make(chan struct{})} + service := sound.New(logger, player, settings.SoundTheme, settings.SoundVolume) + service.Configure(soundConfiguration(settings)) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + close(player.release) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Error(err) + } + }) + if !service.Enqueue(sound.Cue{Name: "start"}) { + t.Fatal("startup cue rejected") + } + var playback context.Context + select { + case playback = <-player.started: + case <-time.After(time.Second): + t.Fatal("startup cue did not reach backend") + } + worker := newPreferencesWorker(logger, settings, nil) + worker.sound = service + next := settings + test.change(&next) + worker.appSettings["Example Application"] = next + worker.switchApplication(context.Background(), "Example Application") + if worker.activeApp != "Example Application" || !reflect.DeepEqual(worker.settings, next) { + t.Fatal("application settings were not applied") + } + if got := playback.Err() == context.Canceled; got != test.cancel { + t.Fatalf("startup playback cancelled = %t, want %t", got, test.cancel) + } + }) + } +} diff --git a/internal/app/steam_activation_test.go b/internal/app/steam_activation_test.go new file mode 100644 index 0000000..42928f1 --- /dev/null +++ b/internal/app/steam_activation_test.go @@ -0,0 +1,274 @@ +// 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 TestWindowTitleChangesSuppressRepeatedNames(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") + activation.Source.Name, activation.Source.Role = "Original title", "frame" + activation.WindowTitle = "Original title" + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "Original title, frame") + changed := activation + changed.Type = atspi.AccessibleNameEvent + for _, title := range []string{"Original title", "Intermediate title", "Updated title", "Updated title", "", "Updated title"} { + changed.Source.Name = title + adapter.events <- changed + } + expectActivationSpeech(t, queue.spoken, "Updated title") + select { + case got := <-queue.spoken: + t.Fatalf("burst queued extra title %q", got) + default: + } +} + +func TestWindowActivationEstablishesFocusedObjectOwnership(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + + focused := atspi.Snapshot{ + BusName: ":1.70", + Path: "/focused", + ApplicationName: "steamwebhelper", + Name: "Library", + Role: "check box", + Focused: true, + } + activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused) + activation.OriginPath = "/window" + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "Library, check box, not checked") + duplicate := activation + duplicate.Source.Name = "Duplicate activation" + adapter.events <- duplicate + select { + case got := <-queue.spoken: + t.Fatalf("duplicate activation spoke %q", got) + case <-time.After(30 * time.Millisecond): + } + + // Accessible paths are process-local. A background process using the same + // path must not be mistaken for the active Steam object. + background := focused + background.BusName = ":1.71" + background.ApplicationName = "Background Application" + background.Name = "Background" + background.Checked = true + changed := atspi.NewEvent("object:state-changed:checked", background) + changed.Detail1 = 1 + adapter.events <- changed + select { + case got := <-queue.spoken: + t.Fatalf("background path collision spoke %q", got) + case <-time.After(30 * time.Millisecond): + } + + focused.Checked = true + changed = atspi.NewEvent("object:state-changed:checked", focused) + changed.Detail1 = 1 + adapter.events <- changed + expectActivationSpeech(t, queue.spoken, "checked") +} + +func TestFocusBeforeActivationAnnouncesWindowTitleOnce(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + + focus := ownerEvent(atspi.FocusedStateEvent, ":1.70", "/window", "/entry") + focus.Source.Name = "Search" + focus.Source.Role = "entry" + focus.Source.Focused = true + focus.Detail1 = 1 + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Search, entry") + + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") + activation.Source.Name = "Example Window" + activation.Source.Role = "frame" + activation.WindowTitle = "Example Window" + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "Example Window") + adapter.events <- activation + select { + case got := <-queue.spoken: + t.Fatalf("repeated activation spoke %q", got) + case <-time.After(30 * time.Millisecond): + } +} + +func TestActiveWindowNameChangeAnnouncesOnlyCurrentTitle(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") + activation.Source.Name = "Original title" + activation.Source.Role = "frame" + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "Original title, frame") + + background := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/other", "/other") + background.Source.Name = "Background title" + background.Source.Role = "frame" + adapter.events <- background + changed := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/window", "/window") + changed.Source.Name = "Updated title" + changed.Source.Role = "frame" + adapter.events <- changed + expectActivationSpeech(t, queue.spoken, "Updated title") + select { + case got := <-queue.spoken: + t.Fatalf("background title spoke %q", got) + case <-time.After(30 * time.Millisecond): + } +} + +func TestRepeatedWindowActivationPreservesFocusedDescendant(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + + window := atspi.Snapshot{ + BusName: ":1.70", + Path: "/window", + ApplicationName: "Example Application", + Name: "Example Window", + Role: "frame", + } + activation := atspi.NewEvent(atspi.WindowActivatedEvent, window) + activation.OriginPath = window.Path + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "Example Window, frame") + + focused := atspi.Snapshot{ + BusName: window.BusName, + Path: "/focused", + ApplicationName: window.ApplicationName, + Name: "Review target", + Role: "check box", + Focused: true, + } + focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, focused) + focusEvent.Detail1 = 1 + adapter.events <- focusEvent + expectActivationSpeech(t, queue.spoken, "Review target, check box, not checked") + + // X11 grabs can cause the same top-level activation to be emitted again + // with the frame as its source. It must neither speak nor replace the + // focused descendant used by review and state-change filtering. + repeated := activation + repeated.Source.Name = "Repeated window activation" + adapter.events <- repeated + focused.Checked = true + changed := atspi.NewEvent("object:state-changed:checked", focused) + changed.Detail1 = 1 + adapter.events <- changed + expectActivationSpeech(t, queue.spoken, "checked") +} + +func TestWindowActivationIsFocusGain(t *testing.T) { + event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{}) + if !isFocusEvent(event.Type) || !isFocusGainEvent(event) { + t.Fatal("window activation was not treated as a focus gain") + } +} + +func TestWindowLifecycleClearsFocusedObjectOwnership(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + + focused := atspi.Snapshot{ + BusName: ":1.70", + Path: "/focused", + ApplicationName: "Example Application", + Name: "Active control", + Role: "check box", + Focused: true, + } + activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused) + activation.Window = atspi.NewObjectID(focused.BusName, "/window") + adapter.events <- activation + expectActivationSpeech(t, queue.spoken, "Active control, check box, not checked") + + deactivation := atspi.NewEvent(atspi.WindowDeactivatedEvent, atspi.Snapshot{ + BusName: focused.BusName, + Path: "/window", + Role: "frame", + }) + deactivation.Window = activation.Window + adapter.events <- deactivation + + changed := atspi.NewEvent("object:state-changed:checked", focused) + changed.Detail1 = 1 + adapter.events <- changed + select { + case got := <-queue.spoken: + t.Fatalf("event from deactivated window spoke %q", got) + case <-time.After(30 * time.Millisecond): + } +} + +func expectActivationSpeech(t *testing.T, spoken <-chan string, want string) { + t.Helper() + select { + case got := <-spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing speech %q", want) + } +} diff --git a/internal/app/table_native_test.go b/internal/app/table_native_test.go new file mode 100644 index 0000000..0eec37f --- /dev/null +++ b/internal/app/table_native_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeBrowserTables(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + keys := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys %v %s", err, out) + } + } + var focus atspi.Snapshot + for attempt := 0; attempt < 25 && focus.Name != "Browser foundation"; attempt++ { + keys("Tab") + timer := time.NewTimer(200 * time.Millisecond) + drain: + for { + select { + case event := <-adapter.Events(): + if isFocusEvent(event.Type) { + focus = event.Source + } + if focus.Name == "Browser foundation" { + break drain + } + case <-timer.C: + break drain + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + timer.Stop() + } + if focus.Name != "Browser foundation" { + t.Fatal("fixture focus missing") + } + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if err = worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.SyncFocus(focus.ID()) + commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err = commands.Start(ctx); err != nil { + t.Fatal(err) + } + defer commands.Stop(context.Background()) + applied := func(enabled bool) { + t.Helper() + commands.SetBrowseNavigation(enabled) + deadline := time.Now().Add(time.Second) + for commands.BrowseNavigationEnabled() != enabled && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if commands.BrowseNavigationEnabled() != enabled { + t.Fatal("browse grabs did not change") + } + } + applied(true) + command := func(chord string) input.Command { + t.Helper() + keys(chord) + select { + case got := <-commands.Commands(): + return got.Command + case err := <-commands.Failures(): + t.Fatal(err) + case <-time.After(2 * time.Second): + t.Fatalf("no command for %s", chord) + } + return "" + } + // Mode changes replace native browse grabs between successive A presses. + if got := command("Insert+a"); got != input.ToggleDocumentMode { + t.Fatal(got) + } + applied(false) + if got := command("Insert+a"); got != input.StickyFocusMode { + t.Fatal(got) + } + if got := command("Insert+a"); got != input.StickyBrowseMode { + t.Fatal(got) + } + applied(true) + result := func() review.Result { + t.Helper() + select { + case got := <-worker.Results(): + if got.Err != nil || got.CaretErr != nil { + t.Fatalf("navigation errors %v %v", got.Err, got.CaretErr) + } + return got + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + return review.Result{} + } + if got := command("t"); got != input.DocumentNextTable { + t.Fatal(got) + } + if err = worker.MoveDocument(review.DocumentTable, 0, false); err != nil { + t.Fatal(err) + } + if got := result(); got.DocumentPosition.Snapshot.Name != "Navigation probe" { + t.Fatalf("table %+v", got) + } + for _, step := range []struct { + key, text string + moved bool + }{ + {"alt+shift+Home", "Item", true}, + {"alt+shift+Right", "Count", true}, + {"alt+shift+Down", "Five", true}, + {"alt+shift+Down", "Spans 2 columns.", true}, + {"alt+shift+Down", "blank Row 4, column 2.", true}, + {"alt+shift+Down", "Bottom of column.", false}, + {"alt+shift+Up", "Merged total", true}, + {"alt+shift+Up", "Five", true}, + {"alt+shift+End", "blank", true}, + {"alt+shift+Left", "Last item", true}, + } { + cmd := command(step.key) + if err = worker.MoveDocumentCaret(documentCaretCommand(cmd)); err != nil { + t.Fatal(err) + } + got := result() + if got.Moved != step.moved || !strings.Contains(got.DocumentCaretText, step.text) { + t.Fatalf("%s got %+v want %s", step.key, got, step.text) + } + if step.text == "Five" && !strings.Contains(got.DocumentCaretText, "Apples") { + t.Fatalf("row header missing: %q", got.DocumentCaretText) + } + if step.text == "Five" && (got.DocumentTableRowHeader != "Apples" || got.DocumentTableColumnHeader != "Count") { + t.Fatalf("table header metadata missing for verbosity: %+v", got) + } + } +} diff --git a/internal/app/tentacle_clipboard.go b/internal/app/tentacle_clipboard.go new file mode 100644 index 0000000..bd03969 --- /dev/null +++ b/internal/app/tentacle_clipboard.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func readClipboardTarget(ctx context.Context, target string) (string, error) { + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out", "-target", target) + command.WaitDelay = time.Second + pipe, err := command.StdoutPipe() + if err != nil { + return "", err + } + if err = command.Start(); err != nil { + return "", err + } + data, readErr := io.ReadAll(io.LimitReader(pipe, 8193)) + if readErr != nil || len(data) > 8192 { + command.Process.Kill() + command.Wait() + return "", errors.New("clipboard too large") + } + err = command.Wait() + return string(data), err +} +func readTentacleClipboard(ctx context.Context) (tentaclewire.Item, error) { + targets, err := readClipboardTarget(ctx, "TARGETS") + if err != nil { + return tentaclewire.Item{}, err + } + for _, target := range strings.Fields(targets) { + if target == "text/uri-list" || target == "x-special/gnome-copied-files" { + data, err := readClipboardTarget(ctx, target) + if err != nil { + return tentaclewire.Item{}, err + } + return singleClipboardURI(data) + } + } + for _, target := range []string{"UTF8_STRING", "text/plain;charset=utf-8", "text/plain", "STRING"} { + for _, available := range strings.Fields(targets) { + if available == target { + text, err := readClipboardTarget(ctx, target) + return tentaclewire.Item{Text: text}, err + } + } + } + for _, target := range strings.Fields(targets) { + if target == "image/png" { + return clipboardPNG(ctx) + } + } + return tentaclewire.Item{}, errors.New("clipboard has no transferable text or file") +} +func singleClipboardURI(data string) (tentaclewire.Item, error) { + // GTK file managers can include a C-string terminator in the X selection. + // Remove only that trailing byte; embedded NULs remain invalid URI data. + data = strings.TrimSuffix(data, "\x00") + var uris []string + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || line == "copy" || line == "cut" { + continue + } + uris = append(uris, line) + } + if len(uris) != 1 { + return tentaclewire.Item{}, errors.New("copy exactly one file or folder") + } + parsed, err := url.Parse(uris[0]) + if err != nil || parsed.Scheme != "file" || parsed.Host != "" && parsed.Host != "localhost" || parsed.RawQuery != "" || parsed.Fragment != "" || !filepath.IsAbs(parsed.Path) || strings.ContainsRune(parsed.Path, '\x00') { + return tentaclewire.Item{}, errors.New("local file path unavailable") + } + return tentaclewire.Item{Path: parsed.Path}, nil +} + +func clipboardPNG(ctx context.Context) (tentaclewire.Item, error) { + directory, err := os.MkdirTemp("", "cthulhu-clipboard-*") + if err != nil { + return tentaclewire.Item{}, err + } + keep := false + defer func() { + if !keep { + os.RemoveAll(directory) + } + }() + path := filepath.Join(directory, "clipboard.png") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return tentaclewire.Item{}, err + } + defer file.Close() + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out", "-target", "image/png") + command.WaitDelay = time.Second + pipe, err := command.StdoutPipe() + if err != nil { + return tentaclewire.Item{}, err + } + if err = command.Start(); err != nil { + return tentaclewire.Item{}, err + } + count, copyErr := io.Copy(file, io.LimitReader(pipe, 32*1024*1024+1)) + if copyErr != nil || count > 32*1024*1024 { + command.Process.Kill() + command.Wait() + return tentaclewire.Item{}, errors.New("clipboard image too large") + } + if err = command.Wait(); err != nil { + return tentaclewire.Item{}, err + } + if count == 0 { + return tentaclewire.Item{}, errors.New("empty clipboard image") + } + if err = file.Close(); err != nil { + return tentaclewire.Item{}, err + } + keep = true + return tentaclewire.Item{Path: path, Temporary: true}, nil +} +func copyTentacleText(ctx context.Context, value string) error { + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in") + command.Stdin = strings.NewReader(value) + return command.Run() +} diff --git a/internal/app/tentacle_clipboard_test.go b/internal/app/tentacle_clipboard_test.go new file mode 100644 index 0000000..a2dcb80 --- /dev/null +++ b/internal/app/tentacle_clipboard_test.go @@ -0,0 +1,92 @@ +package app + +import ( + "bytes" + "context" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + "image" + "image/png" + "net/url" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestTentacleClipboardRequiresOneLocalFile(t *testing.T) { + for _, raw := range []string{"file:///tmp/a%20b\r\n", "copy\nfile:///tmp/a%20b\n", "cut\nfile://localhost/tmp/a%20b\n", "file:///tmp/a%20b\x00", "file:///tmp/a%20b\r\n\x00", "copy\nfile:///tmp/a%20b\n\x00"} { + value, err := singleClipboardURI(raw) + if err != nil || value.Path != "/tmp/a b" { + t.Fatal(value, err) + } + } + for _, raw := range []string{"", "file:///tmp/a\nfile:///tmp/b\n", "file://remote/tmp/a", "https://example.org/a", "file:///tmp/a?query", "file:///tmp/a\x00file:///tmp/b", "file:///tmp/a\x00\x00", "file:///tmp/a%00b", "file:///tmp/a\nfile:///tmp/b\x00"} { + if _, err := singleClipboardURI(raw); err == nil { + t.Fatal("accepted", raw) + } + } +} + +func TestNativeTentacleClipboardTargetsAndTemporaryImage(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_INTERNAL_UI") != "1" { + t.Skip("requires isolated Xorg clipboard") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + set := func(target string, data []byte) { + t.Helper() + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in", "-target", target) + command.Stdin = bytes.NewReader(data) + if err := command.Run(); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(t.TempDir(), "one file.txt") + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + uri := (&url.URL{Scheme: "file", Path: path}).String() + var item tentaclewire.Item + var err error + for _, selection := range []struct{ target, data string }{ + {"text/uri-list", uri + "\r\n"}, + {"text/uri-list", uri + "\x00"}, // GTK file-manager selection terminator. + {"x-special/gnome-copied-files", "copy\n" + uri + "\n\x00"}, + } { + set(selection.target, []byte(selection.data)) + item, err = readTentacleClipboard(ctx) + if err != nil || item.Path != path || item.Temporary { + t.Fatal(selection.target, item, err) + } + } + set("text/uri-list", []byte(uri+"\nfile:///tmp/another\n")) + if _, err = readTentacleClipboard(ctx); err == nil { + t.Fatal("accepted multiple file-manager clipboard items") + } + text := "one\ntwo é猫" + if err = copyTentacleText(ctx, text); err != nil { + t.Fatal(err) + } + item, err = readTentacleClipboard(ctx) + if err != nil || item.Text != text || item.Path != "" { + t.Fatal(item, err) + } + var bitmap bytes.Buffer + if err = png.Encode(&bitmap, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + set("image/png", bitmap.Bytes()) + item, err = readTentacleClipboard(ctx) + if err != nil || !item.Temporary || filepath.Base(item.Path) != "clipboard.png" { + t.Fatal(item, err) + } + defer os.RemoveAll(filepath.Dir(item.Path)) + data, err := os.ReadFile(item.Path) + if err != nil || !bytes.Equal(data, bitmap.Bytes()) { + t.Fatal("temporary clipboard image changed", err) + } + if info, _ := os.Stat(item.Path); info.Mode().Perm() != 0600 { + t.Fatal("temporary image is not private") + } +} diff --git a/internal/app/tentacle_install_test.go b/internal/app/tentacle_install_test.go new file mode 100644 index 0000000..5879d65 --- /dev/null +++ b/internal/app/tentacle_install_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +type installTransport func(*http.Request) (*http.Response, error) + +func (transport installTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func install_fixture(t *testing.T) (tentacle.Repository, []byte) { + t.Helper() + program, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary, err := os.ReadFile(program) + if err != nil { + t.Fatal(err) + } + marker := filepath.Join(t.TempDir(), "called") + manifest := fmt.Sprintf("format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo','-test.run=^TestTentacleReaderPeer$','--','reader-peer',%q]\napis=['commands']\ncapabilities=[]\n[protocol]\nmajor=1\nminor=0\n", marker) + var data bytes.Buffer + compressed := gzip.NewWriter(&data) + archive := tar.NewWriter(compressed) + for _, item := range []struct { + name string + data []byte + mode int64 + }{{"tentacle.toml", []byte(manifest), 0644}, {"bin/echo", binary, 0755}} { + if err := archive.WriteHeader(&tar.Header{Name: item.name, Size: int64(len(item.data)), Mode: item.mode}); err != nil { + t.Fatal(err) + } + if _, err := archive.Write(item.data); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data.Bytes()) + repository := tentacle.Repository{URL: "https://example.org/team/tentacles", ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40), Tentacles: []tentacle.Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1", Releases: []tentacle.Release{{Platform: "linux", Architecture: runtime.GOARCH, URL: "https://example.org/release.tar.gz", SHA256: fmt.Sprintf("%x", digest)}}}}} + return repository, data.Bytes() +} + +func set_install_transport(t *testing.T, transport installTransport) { + t.Helper() + previous := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = previous }) +} + +func TestRepositoryPackageInstallEnableDisableRemoveAndOfflineRestart(t *testing.T) { + test_repository_package_install(t, false) +} + +func TestNativeRepositoryPackageInstall(t *testing.T) { + if os.Getenv("CTHULHU_TENTACLE_MANAGED") != "1" { + t.Skip("requires a systemd user manager and cgroup v2") + } + test_repository_package_install(t, true) +} + +func test_repository_package_install(t *testing.T, contained bool) { + repository, data := install_fixture(t) + downloads := 0 + set_install_transport(t, func(*http.Request) (*http.Response, error) { + downloads++ + return &http.Response{StatusCode: 200, ContentLength: int64(len(data)), Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil + }) + registryPath := filepath.Join(t.TempDir(), "tentacles.toml") + owner, bindings := managed_tentacle_fixture(t, registryPath, contained) + if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); contained && binary != "" { + owner.guardianProgram = []string{binary, "--tentacle-guardian"} + } + owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) { return repository, nil } + if err := owner.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-add", URL: repository.URL}); err != nil { + t.Fatal(err) + } + if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "install", ID: "echo", URL: repository.URL}); err != nil { + t.Fatal(err) + } + id := tentacle.PackageID(repository, "echo") + wait_managed_state(t, owner, id, "disabled") + entry := owner.Snapshot().Entries[0].Entry + if entry.Enabled || downloads != 1 { + t.Fatal(entry, downloads) + } + if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "install-enable", ID: "echo", URL: repository.URL}); err == nil { + t.Fatal("duplicate install accepted") + } + manage_tentacle(t, owner, "enable", id, nil) + wait_managed_state(t, owner, id, "active") + owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) + waitTentacle(t, func() bool { _, err := os.Stat(entry.Program[len(entry.Program)-1]); return err == nil }) + manage_tentacle(t, owner, "disable", id, nil) + wait_managed_state(t, owner, id, "disabled") + if _, err := os.Stat(entry.Program[0]); err != nil { + t.Fatal("disable deleted package", err) + } + manage_tentacle(t, owner, "enable", id, nil) + wait_managed_state(t, owner, id, "active") + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := owner.Stop(shutdown); err != nil { + t.Fatal(err) + } + restarted, _ := managed_tentacle_fixture(t, registryPath, contained) + if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); contained && binary != "" { + restarted.guardianProgram = []string{binary, "--tentacle-guardian"} + } + restarted.fetchRepository = func(context.Context, string) (tentacle.Repository, error) { + t.Error("restart fetched catalog") + return repository, nil + } + if err := restarted.Start(context.Background()); err != nil { + t.Fatal(err) + } + wait_managed_state(t, restarted, id, "active") + if downloads != 1 { + t.Fatal("enable/restart downloaded update", downloads) + } + if err := restarted.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: repository.URL}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(entry.Program[0]); err != nil { + t.Fatal("source removal deleted package", err) + } + manage_tentacle(t, restarted, "remove", id, nil) + waitTentacle(t, func() bool { _, err := os.Stat(entry.PackageDirectory); return os.IsNotExist(err) }) + disk, err := (&tentacle.Registry{Path: registryPath}).Load() + if err != nil || len(disk) != 0 { + t.Fatal(disk, err) + } +} + +func TestRepositoryPackageInstallFailureCancellationAndChangedCatalog(t *testing.T) { + repository, data := install_fixture(t) + for _, problem := range []string{"save failure", "cancel during download", "catalog changed", "emergency disable"} { + t.Run(problem, func(t *testing.T) { + registryPath := filepath.Join(t.TempDir(), "tentacles.toml") + registry := &tentacle.Registry{Path: registryPath} + if _, err := registry.Load(); err != nil { + t.Fatal(err) + } + if err := registry.SaveState(nil, []tentacle.Repository{repository}); err != nil { + t.Fatal(err) + } + owner, _ := managed_tentacle_fixture(t, registryPath, false) + if err := owner.Start(context.Background()); err != nil { + t.Fatal(err) + } + waitTentacle(t, func() bool { return len(owner.Snapshot().Repositories) == 1 }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + set_install_transport(t, func(request *http.Request) (*http.Response, error) { + switch problem { + case "save failure": + if err := os.WriteFile(registryPath, []byte("# external edit\n"), 0600); err != nil { + t.Fatal(err) + } + case "cancel during download": + cancel() + case "catalog changed": + if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: repository.URL}); err != nil { + t.Fatal(err) + } + case "emergency disable": + owner.Disable() + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil + }) + if err := owner.Manage(ctx, tentacle.ManagementRequest{Action: "install-enable", ID: "echo", URL: repository.URL}); err == nil { + t.Fatal("failed installation accepted") + } + if len(owner.Snapshot().Entries) != 0 { + t.Fatal("failed installation registered entry") + } + files, err := os.ReadDir(tentacle.PackageRoot(registryPath)) + if !os.IsNotExist(err) && err != nil { + t.Fatal(err) + } + if len(files) != 0 { + t.Fatal("failed installation left files", files) + } + }) + } +} diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go new file mode 100644 index 0000000..178be0d --- /dev/null +++ b/internal/app/tentacle_interaction.go @@ -0,0 +1,231 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type tentacleForms interface { + Open(context.Context, interaction.Form) (<-chan internalUIResult, error) + Notice(context.Context, tentaclewire.Presentation) +} + +// One interaction per runtime. RPC requests remain short; human input and +// background transfer polling belong to this joined worker, never the input +// consumer. Session revocation cancels its form and retires all results. +func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCommand, raw json.RawMessage) { + select { + case runtime.interactionSlots <- struct{}{}: + default: + runtime.report("A tentacle menu is already open.") + return + } + defer func() { <-runtime.interactionSlots }() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(command.session.OutputContext(), cancel) + defer stop() + var temporaryFiles []string + defer func() { + for _, path := range temporaryFiles { + os.RemoveAll(filepath.Dir(path)) + } + }() + for transitions := 0; transitions < 1024 && ctx.Err() == nil; transitions++ { + value, err := tentaclewire.DecodeInteraction(raw) + if err != nil || !runtime.validContinuation(command, value.Next) || !runtime.validContinuation(command, value.Poll) { + command.session.Disable() + runtime.output.RetireRevoked() + runtime.report("A tentacle returned an invalid interaction and was disabled.") + return + } + if runtime.output.gate.Locked() || runtime.output.gate.Suspended() { + return + } + if value.Clipboard != nil { + copyCtx, stopCopy := context.WithTimeout(ctx, time.Second) + err = copyTentacleText(copyCtx, *value.Clipboard) + stopCopy() + if err != nil { + value.Presentation = clipboardWriteFailure(value.Presentation) + } + } + if value.Form == nil && value.Source == "" { + runtime.output.Present(runtime.output.Capture(command.session.OutputContext()), value.Presentation, "tentacle/"+command.id) + return + } + var submission tentaclewire.Submission + if value.Form != nil { + if runtime.forms == nil { + runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) + runtime.report("Internal UI unavailable.") + return + } + formCtx, closeForm := context.WithCancel(ctx) + replies, openErr := runtime.forms.Open(formCtx, *value.Form) + if openErr != nil { + closeForm() + runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) + runtime.report("Internal UI unavailable.") + return + } + if value.Speech != "" || value.Braille != "" { + runtime.forms.Notice(formCtx, value.Presentation) + } + reply, completed := runtime.waitTentacleForm(formCtx, command, value, replies) + closeForm() + if completed != nil { + // Wait for EndInternalUI before announcing completion or copying text. + select { + case <-replies: + case <-ctx.Done(): + return + } + raw = completed + continue + } + submission = tentaclewire.Submission{Activated: reply.Activated, Values: reply.Values, Cancelled: reply.Cancelled || reply.Err != nil} + } else { + itemCtx, stopItem := context.WithTimeout(ctx, time.Second) + var item tentaclewire.Item + if value.Source == "focus" { + if runtime.focusedItem == nil { + err = errors.New("focused item unavailable") + } else { + item, err = runtime.focusedItem(itemCtx) + } + } else { + item, err = readTentacleClipboard(itemCtx) + if item.Temporary { + temporaryFiles = append(temporaryFiles, item.Path) + item.Temporary = false + } + } + stopItem() + if err != nil { + submission.Error = "The requested item is unavailable or is not a single item" + } else { + submission.Item = &item + } + } + // A cancelled UI still gets a short cleanup invocation while its session is + // live. That lets the tentacle stop its background process on Escape/focus loss. + raw, err = runtime.callInteraction(ctx, command, value.Next, submission) + if err != nil { + return + } + if submission.Cancelled { + return + } + } +} + +func (runtime *tentacleRuntime) validContinuation(command tentacleCommand, id string) bool { + if id == "" { + return true + } + for _, registered := range command.session.Commands() { + if registered.ID == id { + return registered.Interaction && registered.Presentation + } + } + return false +} +func (runtime *tentacleRuntime) callInteraction(ctx context.Context, command tentacleCommand, id string, arguments any) (json.RawMessage, error) { + data, err := json.Marshal(arguments) + if err != nil { + return nil, err + } + replies, err := command.session.Invoke(ctx, id, data) + if err != nil { + return nil, err + } + result := <-replies + return result.Value, result.Err +} +func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command tentacleCommand, value tentaclewire.Interaction, replies <-chan internalUIResult) (internalUIResult, json.RawMessage) { + var ticks <-chan time.Time + if value.Poll != "" { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + ticks = ticker.C + } + for { + select { + case reply := <-replies: + return reply, nil + case <-ctx.Done(): + return internalUIResult{Cancelled: true}, nil + case <-ticks: + raw, err := runtime.callInteraction(ctx, command, value.Poll, tentaclewire.Submission{}) + if err != nil { + return internalUIResult{Cancelled: true}, nil + } + status, err := tentaclewire.DecodeInteraction(raw) + if err != nil || status.Form != nil || status.Source != "" || status.Next != "" { + command.session.Disable() + return internalUIResult{Cancelled: true}, nil + } + if status.Done { + return internalUIResult{}, raw + } + if status.Clipboard != nil { + if runtime.output.gate.Locked() || runtime.output.gate.Suspended() || ctx.Err() != nil { + return internalUIResult{Cancelled: true}, nil + } + copyCtx, stopCopy := context.WithTimeout(ctx, time.Second) + copyErr := copyTentacleText(copyCtx, *status.Clipboard) + stopCopy() + if copyErr != nil { + status.Presentation = clipboardWriteFailure(status.Presentation) + } + } + runtime.forms.Notice(ctx, status.Presentation) + } + } +} + +func focusedTentacleItem(source any, ui *internalUI) func(context.Context) (tentaclewire.Item, error) { + reader, ok := source.(interface { + ReadTransferItem(context.Context, atspi.ObjectID, int) (atspi.TransferItem, error) + }) + if !ok || ui.interest == nil { + return nil + } + return func(ctx context.Context) (tentaclewire.Item, error) { + interest, err := ui.interest.ObjectOfInterest(ctx) + if err != nil { + return tentaclewire.Item{}, err + } + if interest.HasZone { + return tentaclewire.Item{}, errors.New("flat review transfer unavailable") + } + item, err := reader.ReadTransferItem(ctx, interest.Object, interest.Offset) + return tentaclewire.Item{Text: item.Text, Path: item.Path}, err + } +} + +func clipboardWriteFailure(message tentaclewire.Presentation) tentaclewire.Presentation { + if message.Speech == "" { + message.Speech = "Clipboard text could not be saved" + } else { + message.Speech = fmt.Sprintf("%s. Clipboard text could not be saved", message.Speech) + } + if message.Braille == "" { + message.Braille = "Clipboard text could not be saved" + } else { + message.Braille = fmt.Sprintf("%s. Clipboard text could not be saved", message.Braille) + } + return message +} diff --git a/internal/app/tentacle_interaction_test.go b/internal/app/tentacle_interaction_test.go new file mode 100644 index 0000000..f8ad2a7 --- /dev/null +++ b/internal/app/tentacle_interaction_test.go @@ -0,0 +1,194 @@ +package app + +import ( + "context" + "encoding/json" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentacle" + sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTentacleInteractionPeer(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "interaction-peer" { + return + } + marker := os.Args[len(os.Args)-1] + initial := func(context.Context, json.RawMessage) (any, error) { + if filepath.Base(marker) == "copy-failed" { + code := "7-alpha-beta" + return sdk.Interaction{Presentation: sdk.Presentation{Speech: "Wormhole code: " + code, Braille: "Wormhole code: " + code}, Clipboard: &code, Done: true}, nil + } + next := "submit" + if filepath.Base(marker) == "invalid" { + next = "unregistered" + } + return sdk.Interaction{Form: &sdk.Form{Title: "Wormhole menu", Elements: []sdk.Element{{ID: "code", Label: "Wormhole code", Kind: sdk.Entry, Required: true, MaxLength: 256}}}, Next: next}, nil + } + submit := func(_ context.Context, raw json.RawMessage) (any, error) { + os.WriteFile(marker, raw, 0600) + return sdk.Interaction{Presentation: sdk.Presentation{Speech: "Submitted", Braille: "Submitted"}}, nil + } + commands := []sdk.Command{ + {ID: "probe", Title: "Interactive probe", Presentation: true, Interaction: true, Bindings: []sdk.Binding{{Layout: "desktop", Scope: "global", Key: "F9"}}, Handle: initial}, + {ID: "submit", Title: "Submit", Presentation: true, Interaction: true, Handle: submit}, + } + if strings.HasPrefix(filepath.Base(marker), "settings") { + commands = append(commands, sdk.Command{ID: "settings", Title: "Probe settings", Settings: true, Presentation: true, Interaction: true, Handle: initial}) + } + err := sdk.Serve(os.Stdin, os.Stdout, commands) + if err != nil { + os.Exit(1) + } + os.Exit(0) +} +func interactiveFixture(t *testing.T, mode string) (*tentacleRuntime, *internalUI, *uiTestKeys, string) { + t.Helper() + directory := t.TempDir() + marker := filepath.Join(directory, mode) + executable, _ := os.Executable() + specs := []tentacle.Spec{{ID: "probe", Presentation: true, Interaction: true, Program: []string{executable, "-test.run=^TestTentacleInteractionPeer$", "--", "interaction-peer", marker}}} + data, _ := json.Marshal(specs) + path := filepath.Join(directory, "launch.json") + os.WriteFile(path, data, 0600) + bindings := &tentacleBindingRecorder{} + runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, path, "") + runtime.output = tentacleOutputFixture(t) + runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) } + keys := &uiTestKeys{} + output := &ocrTestOutput{speech: make(chan speechRequest, 32)} + ui := &internalUI{keys: keys, speech: output, gate: runtime.output.gate, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), notices: make(chan internalUINotice, 4), done: make(chan struct{})} + runtime.forms = ui + ctx, cancel := context.WithCancel(context.Background()) + go ui.run(ctx) + t.Cleanup(func() { + cancel() + <-ui.done + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + if err := runtime.Stop(stopCtx); err != nil { + t.Error(err) + } + }) + if err := runtime.Start(ctx); err != nil { + t.Fatal(err) + } + waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) != 0 }) + runtime.Invoke(ctx, bindings.TentacleBindings()[0].Command) + return runtime, ui, keys, marker +} +func TestReaderTentacleHumanInputOutlivesRPCDeadline(t *testing.T) { + runtime, ui, keys, marker := interactiveFixture(t, "submitted") + waitTentacle(t, func() bool { return keys.InternalUISession() != 0 }) + // RequestTimeout is two seconds; a user can take longer to type a code. + time.Sleep(2100 * time.Millisecond) + if keys.InternalUISession() == 0 { + t.Fatal("RPC deadline cancelled human input") + } + runtime.output.Retire() // The native command consumer retires output on each key. + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Text: "7-alpha-beta"}) + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff0d}}) + waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) + data, _ := os.ReadFile(marker) + var values sdk.Submission + if json.Unmarshal(data, &values) != nil || values.Values["code"].Text != "7-alpha-beta" || values.Cancelled { + t.Fatalf("submission %s", data) + } + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) + if keys.InternalUISession() != 0 { + t.Fatal("input capture retained after submit") + } +} +func TestReaderTentacleFocusLossAndRevocationCloseForms(t *testing.T) { + for _, mode := range []string{"focus-loss", "disable"} { + t.Run(mode, func(t *testing.T) { + runtime, _, keys, marker := interactiveFixture(t, mode) + waitTentacle(t, func() bool { return keys.InternalUISession() != 0 }) + if mode == "disable" { + runtime.Disable() + } else { + keys.session.Store(0) + } + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) + if keys.InternalUISession() != 0 { + t.Fatal("capture retained") + } + if mode == "focus-loss" { + data, err := os.ReadFile(marker) + var submitted sdk.Submission + if err != nil || json.Unmarshal(data, &submitted) != nil || !submitted.Cancelled { + t.Fatalf("cleanup was not delivered: %s %v", data, err) + } + } + }) + } +} +func TestReaderTentacleRejectsUnregisteredContinuation(t *testing.T) { + runtime, _, keys, _ := interactiveFixture(t, "invalid") + waitTentacle(t, func() bool { + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + return len(runtime.sessions) == 1 && len(runtime.sessions[0].Commands()) == 0 + }) + if keys.InternalUISession() != 0 { + t.Fatal("invalid form was opened") + } +} + +func TestInternalUINoticeCannotReachAnotherForm(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + keys := &uiTestKeys{} + output := &ocrTestOutput{speech: make(chan speechRequest, 16)} + ui := &internalUI{keys: keys, speech: output, gate: &focusGate{}, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), notices: make(chan internalUINotice, 4), done: make(chan struct{})} + go ui.run(ctx) + defer func() { cancel(); <-ui.done }() + form := interaction.Form{Title: "Receive", Elements: []interaction.Element{{ID: "code", Label: "Code", Kind: interaction.Entry}}} + formCtx, closeForm := context.WithCancel(ctx) + first, err := ui.Open(formCtx, form) + if err != nil { + t.Fatal(err) + } + <-output.speech + closeForm() + <-first + secondCtx, closeSecond := context.WithCancel(ctx) + defer closeSecond() + if _, err := ui.Open(secondCtx, form); err != nil { + t.Fatal(err) + } + <-output.speech + ui.Notice(formCtx, sdk.Presentation{Speech: "Stale code"}) + ui.Notice(secondCtx, sdk.Presentation{Speech: "New code"}) + select { + case got := <-output.speech: + if got.Text != "New code" { + t.Fatal(got) + } + case <-time.After(time.Second): + t.Fatal("notice not delivered") + } +} + +func TestReaderTentacleCopyFailureStillReadsCode(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // The private peer is an absolute test executable. + runtime, _, keys, _ := interactiveFixture(t, "copy-failed") + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) + runtime.output.speech.mutex.Lock() + defer runtime.output.speech.mutex.Unlock() + found := false + for _, request := range runtime.output.speech.pending { + if request.Text == "Wormhole code: 7-alpha-beta. Clipboard text could not be saved" { + found = true + } + } + if !found || keys.InternalUISession() != 0 { + t.Fatal("clipboard failure lost the code or retained input") + } +} diff --git a/internal/app/tentacle_management.go b/internal/app/tentacle_management.go new file mode 100644 index 0000000..e170d08 --- /dev/null +++ b/internal/app/tentacle_management.go @@ -0,0 +1,624 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "slices" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +type tentacleManagementCall struct { + ctx context.Context + request tentacle.ManagementRequest + result chan error + repository *tentacle.Repository + packageFiles *tentacle.PreparedPackage +} + +func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot { + if runtime == nil { + return tentacle.Snapshot{Suppressed: true, Message: "Tentacle management is unavailable for this run."} + } + if runtime.registry == nil { + return tentacle.Snapshot{Suppressed: true, Message: "Explicit development launch lists cannot be managed here."} + } + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + result := runtime.snapshot + result.Repositories = tentacle.CloneRepositories(result.Repositories) + result.Entries = append([]tentacle.EntryStatus(nil), result.Entries...) + var bindings []input.BindingOverride + if runtime.input != nil && !runtime.disabled { + bindings = runtime.input.TentacleBindings() + } + for index := range result.Entries { + result.Entries[index].Entry.Program = append([]string(nil), result.Entries[index].Entry.Program...) + if runtime.disabled { + result.Entries[index].State = "suppressed" + } + result.Entries[index].Shortcuts = nil + for _, binding := range bindings { + command, exists := runtime.commands[binding.Command] + if !exists || command.entryID != result.Entries[index].Entry.ID { + continue + } + layout := "Desktop" + if binding.Layout == input.LaptopKeyboardLayout { + layout = "Laptop" + } + for _, key := range binding.Keys { + result.Entries[index].Shortcuts = append(result.Entries[index].Shortcuts, fmt.Sprintf("%s: %s (%s)", layout, key, command.title)) + } + } + } + result.Suppressed = runtime.disabled + return result +} + +// OpenSettings dispatches the explicitly registered settings command using +// the preferences window's lifetime, rather than a short management timeout. +// Settings storage and Save/Cancel belong to the tentacle's existing form. +func (runtime *tentacleRuntime) OpenSettings(ctx context.Context, id string) error { + if runtime == nil || runtime.registry == nil { + return tentacle.ErrUnavailable + } + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + if runtime.disabled { + return tentacle.ErrDisabled + } + index := slices.IndexFunc(runtime.snapshot.Entries, func(status tentacle.EntryStatus) bool { return status.Entry.ID == id }) + if index < 0 { + return errors.New("Select an installed tentacle first.") + } + if !runtime.snapshot.Entries[index].Entry.Enabled { + return errors.New("Enable this tentacle before opening its settings.") + } + if runtime.snapshot.Entries[index].State != "active" { + return errors.New("Tentacle settings are unavailable until the tentacle is active. Refresh status or retry.") + } + for key, command := range runtime.commands { + if command.entryID == id && command.settings { + return runtime.invokeLocked(ctx, key, command) + } + } + return errors.New("No settings for this tentacle.") +} + +// Manage fetches metadata on its caller's worker, then queues a transaction. +// Persistence, start and binding work stays on the lifecycle owner, never the +// native input callback or GTK thread. +func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.ManagementRequest) error { + if runtime == nil || runtime.registry == nil { + return tentacle.ErrUnavailable + } + if request.Action == "list" { + return nil + } + if request.Entry != nil { + entry := *request.Entry + entry.Program = append([]string(nil), entry.Program...) + request.Entry = &entry + } + call := tentacleManagementCall{ctx: ctx, request: request, result: make(chan error, 1)} + runtime.mutex.Lock() + disabled := runtime.disabled + runtime.mutex.Unlock() + if disabled { + return tentacle.ErrDisabled + } + if request.Action == "install" || request.Action == "install-enable" { + if request.Entry != nil { + return errors.New("unexpected tentacle entry in installation request") + } + 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) + if slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) { + return errors.New("tentacle is already installed") + } + runtime.mutex.Lock() + if runtime.repositoryBusy { + runtime.mutex.Unlock() + 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() + if err != nil { + return err + } + defer prepared.Discard() + prepared.Entry.Enabled = request.Action == "install-enable" + call.packageFiles, call.repository = prepared, &repository + call.request = tentacle.ManagementRequest{Action: "add", ID: prepared.Entry.ID, Entry: &prepared.Entry} + } + if request.Action == "repository-add" || request.Action == "repository-refresh" { + if request.Entry != nil || request.ID != "" { + return errors.New("unexpected tentacle entry in repository request") + } + origin, err := tentacle.RepositoryURL(request.URL) + if err != nil { + return err + } + runtime.mutex.Lock() + if runtime.repositoryBusy { + runtime.mutex.Unlock() + return tentacle.ErrBusy + } + runtime.repositoryBusy = true + runtime.mutex.Unlock() + defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() + 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() + if err != nil { + return err + } + call.repository = &repository + } + select { + case runtime.requests <- call: + case <-ctx.Done(): + return ctx.Err() + case <-runtime.done: + return tentacle.ErrUnavailable + default: + return tentacle.ErrBusy + } + if call.packageFiles != nil { + // Once queued, wait for the lifecycle owner's decision before releasing + // files. Returning on caller cancellation here could delete a committed + // package. The owner checks cancellation before saving the registry. + select { + case err := <-call.result: + return err + case <-runtime.done: + return tentacle.ErrUnavailable + } + } + select { + case err := <-call.result: + return err + case <-ctx.Done(): + return ctx.Err() + case <-runtime.done: + return tentacle.ErrUnavailable + } +} + +func change_tentacle_entries(entries []tentacle.Entry, request tentacle.ManagementRequest) ([]tentacle.Entry, error) { + next := tentacle.CloneEntries(entries) + index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == request.ID }) + if request.Action == "add" || request.Action == "update" { + if request.Entry == nil || request.Entry.ID != request.ID { + return nil, errors.New("a matching tentacle entry is required") + } + } else if request.Entry != nil { + return nil, errors.New("unexpected tentacle entry") + } + if request.Action != "add" && index < 0 { + return nil, errors.New("tentacle does not exist") + } + switch request.Action { + case "add": + if index >= 0 { + return nil, errors.New("tentacle already exists") + } + next = append(next, *request.Entry) + case "update": + next[index] = *request.Entry + case "remove": + next = slices.Delete(next, index, index+1) + case "enable": + next[index].Enabled = true + case "disable": + next[index].Enabled = false + case "retry": + if !next[index].Enabled { + return nil, errors.New("enable the tentacle before retrying") + } + default: + return nil, errors.New("unknown tentacle operation") + } + 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) + removedPackages := make(map[string]tentacle.Entry) + 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 delegation *tentacle.Delegation + var serviceDone <-chan struct{} + 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) + } + 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, serviceError error + if manager != nil { + managerError = manager.Shutdown(cleanup) + } + if delegation != nil { + serviceError = delegation.Close(cleanup) + } + for _, entry := range removedPackages { + 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, serviceError) + 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 + if runtime.newManager != nil { + manager, err = runtime.newManager() + return err + } + parent := runtime.parent + if parent == "" || parent == "auto" { + program := runtime.guardianProgram + if len(program) == 0 { + executable, err := os.Executable() + if err != nil { + return err + } + program = []string{executable, "--tentacle-guardian"} + } + if delegation == nil { + delegation, err = tentacle.StartDelegation(ctx, program) + if err != nil { + return err + } + serviceDone = delegation.Done() + } + parent = delegation.Parent + } + if delegation != nil { + manager, err = tentacle.NewDelegatedManager(delegation) + } else { + manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, parent, tentacle.DefaultResourceLimits()) + } + return err + } + revoke := func(id string) { + if session := sessions[id]; session != nil { + session.Disable() + runtime.output.RetireRevoked() + } + } + for { + if ctx.Err() != nil { + return nil + } + // 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 id, entry := range removedPackages { + if sessions[id] != nil { + 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.") + } + delete(removedPackages, id) + } + 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 { + problems[entry.ID] = "Tentacle could not start; check resource containment and its program, then retry." + 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() + notify() + <-session.Done() + notify() + }() + break + } + } + setSnapshot() + select { + case <-ctx.Done(): + return nil + case <-serviceDone: + // A lost containment owner invalidates every session. Do not relaunch + // into a disappearing cgroup or run uncontained. + return tentacle.ErrContainment + 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 + } + } + next, err := change_tentacle_entries(entries, call.request) + if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, entries) { + 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 + if call.packageFiles != nil { + call.packageFiles.Retain() + } + if call.request.Action == "remove" { + for _, entry := range entries { + if entry.ID == id && entry.PackageDirectory != "" { + removedPackages[id] = entry + } + } + } + revoke(id) + 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() + } + call.result <- err + case <-ready: + session := current + current, ready = nil, nil + if state, _ := session.Status(); state != tentacle.Ready { + 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.") + continue + } + if err := session.Activate(); err != nil { + session.Disable() + if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { + select { + case runtime.failures <- err: + default: + } + return err + } + continue + } + active = candidate + runtime.publishCommands(active) + runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity()) + } + } +} diff --git a/internal/app/tentacle_management_test.go b/internal/app/tentacle_management_test.go new file mode 100644 index 0000000..4d9d6ef --- /dev/null +++ b/internal/app/tentacle_management_test.go @@ -0,0 +1,283 @@ +package app + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func managed_tentacle_fixture(t *testing.T, path string, contained bool) (*tentacleRuntime, *tentacleBindingRecorder) { + t.Helper() + bindings := &tentacleBindingRecorder{} + runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, "", "auto") + runtime.registry = &tentacle.Registry{Path: path} + runtime.requests = make(chan tentacleManagementCall, 16) + if !contained { + runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) } + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil { + t.Error(err) + } + }) + return runtime, bindings +} + +func manage_tentacle(t *testing.T, runtime *tentacleRuntime, action, id string, entry *tentacle.Entry) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if err := runtime.Manage(ctx, tentacle.ManagementRequest{Action: action, ID: id, Entry: entry}); err != nil { + t.Fatalf("%s: %v", action, err) + } +} + +func probe_entry(t *testing.T, id, mode string) tentacle.Entry { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return tentacle.Entry{ID: id, Program: []string{executable, "-test.run=^TestTentacleReaderPeer$", "--", "reader-peer", filepath.Join(t.TempDir(), mode)}} +} + +func wait_managed_state(t *testing.T, runtime *tentacleRuntime, id, state string) { + t.Helper() + waitTentacle(t, func() bool { + for _, status := range runtime.Snapshot().Entries { + if status.Entry.ID == id { + return status.State == state + } + } + return false + }) +} + +func TestManagedTentaclePersistenceLifecycleAndRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "tentacles.toml") + runtime, bindings := managed_tentacle_fixture(t, path, false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + first := probe_entry(t, "first", "called") + second := probe_entry(t, "second", "called") + manage_tentacle(t, runtime, "add", first.ID, &first) + manage_tentacle(t, runtime, "add", second.ID, &second) + if len(bindings.TentacleBindings()) != 0 { + t.Fatal("adding disabled entries launched them") + } + manage_tentacle(t, runtime, "enable", first.ID, nil) + wait_managed_state(t, runtime, first.ID, "active") + firstCommand := bindings.TentacleBindings()[0].Command + manage_tentacle(t, runtime, "enable", first.ID, nil) + if bindings.TentacleBindings()[0].Command != firstCommand { + t.Fatal("enable of active entry restarted it") + } + manage_tentacle(t, runtime, "enable", second.ID, nil) + wait_managed_state(t, runtime, second.ID, "active") + manage_tentacle(t, runtime, "disable", first.ID, nil) + wait_managed_state(t, runtime, first.ID, "disabled") + waitTentacle(t, func() bool { + bound := bindings.TentacleBindings() + return len(bound) > 0 && bound[0].Command != firstCommand + }) + runtime.Invoke(context.Background(), firstCommand) + if _, err := os.Stat(first.Program[len(first.Program)-1]); !os.IsNotExist(err) { + t.Fatal("stale command invoked disabled child") + } + secondCommand := bindings.TentacleBindings()[0].Command + manage_tentacle(t, runtime, "retry", second.ID, nil) + wait_managed_state(t, runtime, second.ID, "active") + if bindings.TentacleBindings()[0].Command == secondCommand { + t.Fatal("retry reused old session identity") + } + loaded, err := (&tentacle.Registry{Path: path}).Load() + if err != nil || len(loaded) != 2 || loaded[0].Enabled || !loaded[1].Enabled { + t.Fatalf("persisted state: %v %v", loaded, err) + } + runtime.Disable() + if err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "enable", ID: first.ID}); !errors.Is(err, tentacle.ErrDisabled) { + t.Fatalf("emergency bypass: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil { + t.Fatal(err) + } + restarted, _ := managed_tentacle_fixture(t, path, false) + if err := restarted.Start(context.Background()); err != nil { + t.Fatal(err) + } + wait_managed_state(t, restarted, second.ID, "active") + wait_managed_state(t, restarted, first.ID, "disabled") + manage_tentacle(t, restarted, "remove", first.ID, nil) + manage_tentacle(t, restarted, "remove", second.ID, nil) + loaded, err = (&tentacle.Registry{Path: path}).Load() + if err != nil || len(loaded) != 0 { + t.Fatalf("remove all: %v %v", loaded, err) + } + if _, err := os.Stat(second.Program[0]); err != nil { + t.Fatal("removing configuration deleted the executable") + } +} + +func TestManagedTentacleFailureRetryAndSaveFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "tentacles.toml") + runtime, bindings := managed_tentacle_fixture(t, path, false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + entry := probe_entry(t, "probe", "crash") + entry.Enabled = true + manage_tentacle(t, runtime, "add", entry.ID, &entry) + wait_managed_state(t, runtime, entry.ID, "active") + runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) + wait_managed_state(t, runtime, entry.ID, "failed") + if !runtime.Snapshot().Entries[0].Entry.Enabled { + t.Fatal("failure changed desired enablement") + } + manage_tentacle(t, runtime, "retry", entry.ID, nil) + wait_managed_state(t, runtime, entry.ID, "active") + entry.Program[len(entry.Program)-1] = filepath.Join(t.TempDir(), "called") + entry.Presentation = true + manage_tentacle(t, runtime, "update", entry.ID, &entry) + wait_managed_state(t, runtime, entry.ID, "active") + if !runtime.Snapshot().Entries[0].Entry.Presentation { + t.Fatal("grant lost") + } + if err := os.WriteFile(path, []byte("version = 1\n"), 0600); err != nil { + t.Fatal(err) + } + if err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "disable", ID: entry.ID}); err == nil { + t.Fatal("overwrote external edits") + } + if runtime.Snapshot().Entries[0].State != "active" { + t.Fatal("failed persistence changed runtime") + } +} + +func TestManagedTentacleEmptyAndBrokenRegistryDoNotLaunch(t *testing.T) { + for _, broken := range []bool{false, true} { + path := filepath.Join(t.TempDir(), "tentacles.toml") + if broken { + if err := os.WriteFile(path, []byte("invalid"), 0600); err != nil { + t.Fatal(err) + } + } + runtime, _ := managed_tentacle_fixture(t, path, false) + runtime.newManager = func() (*tentacle.Manager, error) { + t.Error("empty/broken registry initialized host") + return nil, tentacle.ErrUnavailable + } + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + entry := probe_entry(t, "probe", "called") + err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "add", ID: entry.ID, Entry: &entry}) + if (err != nil) != broken { + t.Fatalf("broken=%v: %v", broken, err) + } + if len(runtime.sessions) != 0 { + t.Fatal("launched unexpected child") + } + } +} + +func TestPreferencesTentacleOperationsAreGlobalAndSeparate(t *testing.T) { + runtime, _ := managed_tentacle_fixture(t, filepath.Join(t.TempDir(), "tentacles.toml"), false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) + worker.tentacles = runtime + entry := probe_entry(t, "probe", "called") + request := preferences.Request{Tentacle: &tentacle.ManagementRequest{Action: "add", ID: entry.ID, Entry: &entry}} + worker.editApp = "Example" + if response := worker.manage_tentacle(context.Background(), request); response.Accepted { + t.Fatal("application preferences added a program") + } + worker.editApp = "" + request.Save = true + if response := worker.manage_tentacle(context.Background(), request); response.Accepted { + t.Fatal("mixed settings request accepted") + } + request.Save = false + if response := worker.manage_tentacle(context.Background(), request); !response.Accepted || response.Tentacles == nil || len(response.Tentacles.Entries) != 1 { + t.Fatalf("add: %+v", response) + } +} + +func TestManagedTentacleGuardianPeer(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "managed-guardian" { + return + } + if err := tentacle.RunGuardian(os.Stdin, os.Stdout, os.Args[len(os.Args)-1]); err != nil { + os.Exit(1) + } + os.Exit(0) +} + +func TestNativeManagedTentacle(t *testing.T) { + if os.Getenv("CTHULHU_TENTACLE_MANAGED") != "1" { + t.Skip("requires a systemd user manager and cgroup v2") + } + runtime, bindings := managed_tentacle_fixture(t, filepath.Join(t.TempDir(), "tentacles.toml"), true) + readerGroup, err := os.ReadFile("/proc/self/cgroup") + if err != nil { + t.Fatal(err) + } + runtime.logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + defer func() { t.Logf("final status: %+v", runtime.Snapshot()) }() + executable, _ := os.Executable() + runtime.guardianProgram = []string{executable, "-test.run=^TestManagedTentacleGuardianPeer$", "--", "managed-guardian"} + if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); binary != "" { + runtime.guardianProgram = []string{binary, "--tentacle-guardian"} + } + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + entry := probe_entry(t, "probe", "called") + entry.Enabled = true + manage_tentacle(t, runtime, "add", entry.ID, &entry) + wait_managed_state(t, runtime, entry.ID, "active") + runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) + marker := entry.Program[len(entry.Program)-1] + waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) + membership, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + childGroup := filepath.Join("/sys/fs/cgroup", strings.TrimSpace(strings.TrimPrefix(string(membership), "0::"))) + for name, want := range map[string]string{"cpu.max": "25000 100000", "memory.max": "268435456", "pids.max": "64"} { + value, err := os.ReadFile(filepath.Join(childGroup, name)) + if err != nil || strings.TrimSpace(string(value)) != want { + t.Fatalf("%s: %q %v", name, value, err) + } + } + afterGroup, err := os.ReadFile("/proc/self/cgroup") + if err != nil || string(readerGroup) != string(afterGroup) { + t.Fatal("management moved the reader") + } + manage_tentacle(t, runtime, "disable", entry.ID, nil) + waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) == 0 }) + manage_tentacle(t, runtime, "enable", entry.ID, nil) + wait_managed_state(t, runtime, entry.ID, "active") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil { + t.Fatal(err) + } + serviceGroup := filepath.Dir(filepath.Dir(childGroup)) + waitTentacle(t, func() bool { _, err := os.Stat(serviceGroup); return os.IsNotExist(err) }) +} diff --git a/internal/app/tentacle_output.go b/internal/app/tentacle_output.go new file mode 100644 index 0000000..f7a93b4 --- /dev/null +++ b/internal/app/tentacle_output.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "sync" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type tentacleOutputTicket struct { + ctx context.Context + session context.Context + speechGeneration uint64 + speechRevision uint64 + brailleSequence uint64 +} + +// tentacleOutput owns one explicit command's presentation lifetime. Any later +// command, foreground/mode transition or accessibility event retires it. This +// deliberately conservative revision gate exposes no accessible text/positions. +// Speech's interruption generation and braille's frame sequence are independent. +// Lock order: runtime, focus gate, output owner, then speech/braille owner. +type tentacleOutput struct { + mutex sync.Mutex + speech *speechWorker + braille *brailleWorker + gate *focusGate + live *liveSettings + current context.Context + cancel context.CancelFunc +} + +func (owner *tentacleOutput) Capture(parent context.Context) tentacleOutputTicket { + if owner == nil { + return tentacleOutputTicket{} + } + owner.mutex.Lock() + defer owner.mutex.Unlock() + owner.retireLocked() + ctx, cancel := context.WithCancel(parent) + owner.current, owner.cancel = ctx, cancel + ticket := tentacleOutputTicket{ctx: ctx, session: parent} + if owner.speech != nil { + owner.speech.mutex.Lock() + ticket.speechGeneration = owner.speech.generation + ticket.speechRevision = owner.speech.presentationRevision + owner.speech.mutex.Unlock() + } + if owner.braille != nil { + owner.braille.mu.Lock() + ticket.brailleSequence = owner.braille.sequence + owner.braille.mu.Unlock() + } + return ticket +} + +func (owner *tentacleOutput) Retire() { + if owner == nil { + return + } + owner.mutex.Lock() + defer owner.mutex.Unlock() + owner.retireLocked() +} + +// Stop additionally revokes accepted protected speech during reader shutdown +// or terminal handoff. Ordinary focus/command retirement only retires replies. +func (owner *tentacleOutput) Stop() { + if owner == nil { + return + } + owner.mutex.Lock() + defer owner.mutex.Unlock() + owner.retireLocked() + if owner.speech != nil { + owner.speech.stop_uninterruptible() + } +} + +func (owner *tentacleOutput) RetireRevoked() { + if owner == nil { + return + } + owner.mutex.Lock() + defer owner.mutex.Unlock() + if owner.current != nil && owner.current.Err() != nil { + owner.retireLocked() + } +} + +func (owner *tentacleOutput) retireLocked() { + if owner.cancel == nil { + return + } + owner.cancel() + if owner.speech != nil { + owner.speech.revokePresentation(owner.current) + } + if owner.braille != nil { + owner.braille.retirePresentation(owner.current) + } + owner.current, owner.cancel = nil, nil +} + +func (owner *tentacleOutput) Present(ticket tentacleOutputTicket, value tentaclewire.Presentation, command string) { + if owner == nil || ticket.ctx == nil { + return + } + if owner.gate != nil { + owner.gate.mutex.Lock() + defer owner.gate.mutex.Unlock() + } + owner.mutex.Lock() + defer owner.mutex.Unlock() + if ticket.ctx != owner.current || ticket.ctx.Err() != nil || owner.gate.Locked() || owner.gate.Suspended() { + return + } + if value.Speech != "" && owner.speech != nil { + request := speechRequest{Text: value.Speech, Role: "system", Cause: command, + context: ticket.ctx, presentationGeneration: &ticket.speechGeneration, presentationRevision: &ticket.speechRevision} + if value.Uninterruptible { + // Admission still checks the ticket; accepted speech belongs to the + // session so subsequent focus/command changes cannot cancel it. + request.Uninterruptible = true + request.context, request.cancel = context.WithTimeout(ticket.session, uninterruptibleSpeechTimeout) + } + owner.speech.Enqueue(request) + } + if value.Braille != "" && owner.braille != nil && owner.live != nil { + settings := owner.live.Load() + if settings.EnableBraille { + if owner.braille.flash_message_guarded(value.Braille, settings.Braille, false, ticket.ctx, ticket.brailleSequence) { + owner.braille.mu.Lock() + if owner.braille.flashContext == ticket.ctx { + owner.braille.logger.Debug("queued tentacle braille message", "command", command, + "frame_id", owner.braille.frame.ID, "braille_text", owner.braille.frame.Text) + } + owner.braille.mu.Unlock() + } + } + } +} diff --git a/internal/app/tentacle_output_test.go b/internal/app/tentacle_output_test.go new file mode 100644 index 0000000..97ede5a --- /dev/null +++ b/internal/app/tentacle_output_test.go @@ -0,0 +1,215 @@ +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func tentacleOutputFixture(t *testing.T) *tentacleOutput { + t.Helper() + live := &liveSettings{} + settings := config.DefaultSettings() + settings.EnableBraille = true + settings.Braille.FlashMessages = true + settings.Braille.FlashPersistent = true + live.Store(settings) + display := &brailleWorker{logger: slog.New(slog.DiscardHandler), cancel: func() {}, connected: true, wake: make(chan struct{}, 1), + frame: braille.Frame{Text: "Core focus", Cursor: -1}, baseFrame: braille.Frame{Text: "Core focus", Cursor: -1}} + speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil) + speech.running = true // Inspect the bounded queue without a delivery goroutine. + owner := &tentacleOutput{speech: speech, braille: display, gate: newFocusGate(nil), live: live} + owner.gate.tentacleOutput = owner + t.Cleanup(owner.Retire) + return owner +} + +func TestTentacleIndependentOutputOwnership(t *testing.T) { + for _, change := range []string{"none", "speech interrupt", "new speech", "new braille", "braille disabled", "flashes disabled"} { + t.Run(change, func(t *testing.T) { + owner := tentacleOutputFixture(t) + ticket := owner.Capture(context.Background()) + expectSpeech, expectBraille := true, true + switch change { + case "speech interrupt": + owner.speech.InterruptForUser() + expectSpeech = false + case "new speech": + owner.speech.Enqueue(speechRequest{Text: "New core speech"}) + expectSpeech = false + case "new braille": + owner.braille.Enqueue("New core braille") + expectBraille = false + case "braille disabled": + settings := owner.live.Load() + settings.EnableBraille = false + owner.live.Store(settings) + expectBraille = false + case "flashes disabled": + settings := owner.live.Load() + settings.Braille.FlashMessages = false + owner.live.Store(settings) + expectBraille = false + } + owner.Present(ticket, tentaclewire.Presentation{Speech: "Tentacle speech", Braille: "Tentacle braille"}, "tentacle/test/probe") + gotSpeech := false + for _, request := range owner.speech.pending { + if request.Text == "Tentacle speech" { + gotSpeech = true + if request.context != ticket.ctx || request.ForceSpeech || request.BypassFocusGate || request.Role != "system" { + t.Fatal("unsafe speech request") + } + } + } + if gotSpeech != expectSpeech || (owner.braille.current().Text == "Tentacle braille") != expectBraille { + t.Fatal("channel ownership bypassed") + } + if len(owner.braille.current().Routes) != 0 { + t.Fatal("message gained routing targets") + } + }) + } +} + +func TestTentacleRetiredAndBufferedOutput(t *testing.T) { + for _, change := range []string{"revision", "new command", "revoked", "handoff and resume", "lock and unlock"} { + t.Run(change, func(t *testing.T) { + owner := tentacleOutputFixture(t) + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + ticket := owner.Capture(parent) + switch change { + case "revision": + owner.gate.retireTentacleOutput() + case "new command": + owner.Capture(context.Background()) + case "revoked": + cancel() + case "handoff and resume": + owner.gate.SetSuspended(true) + owner.gate.SetSuspended(false) + case "lock and unlock": + owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille) + owner.gate.SetLocked(false, nil, nil, nil, owner.speech, owner.braille) + } + owner.Present(ticket, tentaclewire.Presentation{Speech: "Late speech", Braille: "Late braille"}, "tentacle/test/probe") + if len(owner.speech.pending) != 0 || owner.braille.current().Text == "Late braille" { + t.Fatal("retired output was accepted") + } + }) + } +} + +func TestTentacleAcceptedOutputRetirement(t *testing.T) { + for _, newer := range []bool{false, true} { + owner := tentacleOutputFixture(t) + parent, cancel := context.WithCancel(context.Background()) + ticket := owner.Capture(parent) + owner.Present(ticket, tentaclewire.Presentation{Speech: "Queued speech", Braille: "Tentacle flash"}, "tentacle/test/probe") + if len(owner.speech.pending) != 1 || owner.braille.current().Text != "Tentacle flash" { + t.Fatal("presentation not queued") + } + if newer { + owner.speech.Enqueue(speechRequest{Text: "New core output"}) + owner.braille.Flash("New core flash", owner.live.Load().Braille) + } + cancel() // Reply was already consumed; its session still owns output lifetime. + if !newer && owner.braille.current().Text != "Core focus" { + t.Fatal("revoked flash survived consumption") + } + owner.RetireRevoked() + if newer { + if owner.braille.current().Text != "New core flash" || len(owner.speech.pending) != 1 || owner.speech.pending[0].Text != "New core output" { + t.Fatal("retirement displaced core output") + } + } else if len(owner.speech.pending) != 0 { + t.Fatal("queued retired speech remains") + } + } +} + +func TestTentacleInitiatingInputAndLaterInterruption(t *testing.T) { + owner := tentacleOutputFixture(t) + owner.speech.ProtectCommand(100) + ticket := owner.Capture(context.Background()) + if owner.speech.InterruptForInput(input.Event{Timestamp: 100, Pressed: true}) { + t.Fatal("initiating chord interrupted command") + } + owner.Present(ticket, tentaclewire.Presentation{Speech: "Command result"}, "tentacle/test/probe") + if len(owner.speech.pending) != 1 { + t.Fatal("initiating input retired speech") + } + owner.speech.InterruptForInput(input.Event{Timestamp: 101, Pressed: true}) + if len(owner.speech.pending) != 0 { + t.Fatal("later input failed to interrupt") + } +} + +func TestTentacleCoreSpeechModes(t *testing.T) { + for _, mode := range []speechMode{speechModeSpeech, speechModeBeeps, speechModeOff} { + t.Run(mode.announcement(), func(t *testing.T) { + owner := tentacleOutputFixture(t) + speaker := &fakeSpeaker{spoken: make(chan string, 1)} + owner.speech.speaker = speaker + owner.speech.mode = mode + beeps := 0 + owner.speech.beep = func(context.Context) error { beeps++; return nil } + ticket := owner.Capture(context.Background()) + owner.Present(ticket, tentaclewire.Presentation{Speech: "Hello from a tentacle.", Braille: "Display message"}, "tentacle/test/probe") + request, ok := owner.speech.next() + if !ok { + t.Fatal("no queued presentation") + } + if err := owner.speech.deliver(request.context, request); err != nil { + t.Fatal(err) + } + if (len(speaker.spoken) == 1) != (mode == speechModeSpeech) || (beeps == 1) != (mode == speechModeBeeps) { + t.Fatal("tentacle bypassed core speech mode") + } + if owner.braille.current().Text != "Display message" { + t.Fatal("speech mode suppressed independent braille") + } + }) + } +} + +func TestTentacleRevocationPreservesQueuedCoreSpeech(t *testing.T) { + owner := tentacleOutputFixture(t) + speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} + owner.speech.speaker = speaker + owner.speech.running = false + if err := owner.speech.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { owner.speech.Stop(context.Background()) }) + ticket := owner.Capture(context.Background()) + owner.Present(ticket, tentaclewire.Presentation{Speech: "Tentacle output"}, "tentacle/test/probe") + close(expectSpeechCall(t, speaker, "cancel").done) + _ = expectSpeechCall(t, speaker, "Tentacle output") // Remains in flight until context cancellation. + owner.speech.Enqueue(speechRequest{Text: "Later core speech", Append: true}) + owner.Retire() + cancellations := 0 + deadline := time.After(time.Second) + for { + select { + case call := <-speaker.calls: + close(call.done) + if call.text == "cancel" { + cancellations++ + continue + } + if call.text != "Later core speech" || cancellations == 0 { + t.Fatalf("unexpected delivery after revocation: %q", call.text) + } + return + case <-deadline: + t.Fatal("core speech failed to resume after revocation") + } + } +} diff --git a/internal/app/tentacle_repositories.go b/internal/app/tentacle_repositories.go new file mode 100644 index 0000000..280969e --- /dev/null +++ b/internal/app/tentacle_repositories.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "errors" + "slices" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func change_tentacle_repositories(repositories []tentacle.Repository, request tentacle.ManagementRequest, repository *tentacle.Repository) ([]tentacle.Repository, error) { + if request.Entry != nil || request.ID != "" { + return nil, errors.New("unexpected tentacle entry in repository request") + } + origin, err := tentacle.RepositoryURL(request.URL) + if err != nil { + return nil, err + } + next := tentacle.CloneRepositories(repositories) + index := slices.IndexFunc(next, func(value tentacle.Repository) bool { return value.URL == origin }) + switch request.Action { + case "repository-add", "repository-refresh": + if repository == nil || repository.URL != origin { + return nil, errors.New("validated repository metadata is required") + } + candidate := tentacle.CloneRepositories([]tentacle.Repository{*repository})[0] + if request.Action == "repository-add" { + if index >= 0 { + return nil, errors.New("repository already added") + } + next = append(next, candidate) + } else { + if index < 0 { + return nil, errors.New("repository does not exist") + } + if next[index].ID != repository.ID { + return nil, errors.New("repository identity changed; the previous catalog was retained") + } + next[index] = candidate + } + case "repository-remove": + if index < 0 { + return nil, errors.New("repository does not exist") + } + next = slices.Delete(next, index, index+1) + default: + return nil, errors.New("unknown repository operation") + } + return next, tentacle.ValidateRepositories(next) +} diff --git a/internal/app/tentacle_repositories_test.go b/internal/app/tentacle_repositories_test.go new file mode 100644 index 0000000..0161522 --- /dev/null +++ b/internal/app/tentacle_repositories_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "path/filepath" + "strings" + "testing" +) + +func TestManagedRepositoryDiscoveryRefreshRemoveAndRestart(t *testing.T) { + registryPath := filepath.Join(t.TempDir(), "tentacles.toml") + runtime, _ := managed_tentacle_fixture(t, registryPath, false) + origin := "https://example.org/team/tentacles" + repository := tentacle.Repository{URL: origin, ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40), Tentacles: []tentacle.Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1"}}} + fail := false + runtime.fetchRepository = func(context.Context, string) (tentacle.Repository, error) { + if fail { + return tentacle.Repository{}, errors.New("invalid metadata") + } + return repository, nil + } + runtime.newManager = func() (*tentacle.Manager, error) { + t.Error("repository discovery started host") + return nil, tentacle.ErrUnavailable + } + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + manage := func(action string) error { + return runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: action, URL: origin}) + } + if err := manage("repository-add"); err != nil { + t.Fatal(err) + } + if len(runtime.Snapshot().Repositories) != 1 || len(runtime.Snapshot().Entries) != 0 { + t.Fatal(runtime.Snapshot()) + } + if err := manage("repository-add"); err == nil { + t.Fatal("duplicate accepted") + } + fail = true + if err := manage("repository-refresh"); err == nil { + t.Fatal("invalid refresh accepted") + } + if runtime.Snapshot().Repositories[0].Revision != repository.Revision { + t.Fatal("lost last good catalog") + } + fail = false + repository.Revision = strings.Repeat("b", 40) + if err := manage("repository-refresh"); err != nil { + t.Fatal(err) + } + repository.ID = "different" + if err := manage("repository-refresh"); err == nil { + t.Fatal("changed identity accepted") + } + disk := &tentacle.Registry{Path: registryPath} + if _, err := disk.Load(); err != nil || len(disk.Repositories()) != 1 || disk.Repositories()[0].ID != "example.tentacles" || disk.Repositories()[0].Revision != strings.Repeat("b", 40) { + t.Fatalf("persisted repository: %+v %v", disk.Repositories(), err) + } + // Cached catalogs load at startup without invoking any fetch function. + restarted, _ := managed_tentacle_fixture(t, registryPath, false) + restarted.fetchRepository = func(context.Context, string) (tentacle.Repository, error) { + t.Error("startup fetched repository") + return repository, nil + } + if err := restarted.Start(context.Background()); err != nil { + t.Fatal(err) + } + waitTentacle(t, func() bool { return len(restarted.Snapshot().Repositories) == 1 }) + if err := manage("repository-remove"); err != nil { + t.Fatal(err) + } + if len(runtime.Snapshot().Repositories) != 0 { + t.Fatal("source not removed") + } +} + +func TestRepositoryRemovalRetainsInstalledEntries(t *testing.T) { + registryPath := filepath.Join(t.TempDir(), "tentacles.toml") + origin := "https://example.org/team/tentacles" + registry := &tentacle.Registry{Path: registryPath} + if _, err := registry.Load(); err != nil { + t.Fatal(err) + } + entry := probe_entry(t, "installed", "called") + entry.Repository = origin + entry.RepositoryID = "example.tentacles" + entry.LocalID = "echo" + repository := tentacle.Repository{URL: origin, ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40)} + if err := registry.SaveState([]tentacle.Entry{entry}, []tentacle.Repository{repository}); err != nil { + t.Fatal(err) + } + runtime, _ := managed_tentacle_fixture(t, registryPath, false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: origin}); err != nil { + t.Fatal(err) + } + snapshot := runtime.Snapshot() + if len(snapshot.Repositories) != 0 || len(snapshot.Entries) != 1 || snapshot.Entries[0].Entry.ID != entry.ID { + t.Fatal(snapshot) + } +} diff --git a/internal/app/tentacle_settings_test.go b/internal/app/tentacle_settings_test.go new file mode 100644 index 0000000..d37460f --- /dev/null +++ b/internal/app/tentacle_settings_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "testing" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func TestPreferencesTentacleSettingsDispatchAndMissingSettings(t *testing.T) { + for _, mode := range []string{"settings-save", "settings-cancel", "settings-disable", "no-settings"} { + t.Run(mode, func(t *testing.T) { + directory := t.TempDir() + path, marker := filepath.Join(directory, "tentacles.toml"), filepath.Join(directory, mode) + runtime, _ := managed_tentacle_fixture(t, path, false) + runtime.output = tentacleOutputFixture(t) + keys := &uiTestKeys{} + output := &ocrTestOutput{speech: make(chan speechRequest, 32)} + ui := &internalUI{keys: keys, speech: output, gate: runtime.output.gate, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), notices: make(chan internalUINotice, 4), done: make(chan struct{})} + runtime.forms = ui + ctx, cancel := context.WithCancel(context.Background()) + go ui.run(ctx) + t.Cleanup(func() { cancel(); <-ui.done }) + if err := runtime.Start(ctx); err != nil { + t.Fatal(err) + } + executable, _ := os.Executable() + entry := tentacle.Entry{ID: "probe", Enabled: true, Presentation: true, Interaction: true, Program: []string{executable, "-test.run=^TestTentacleInteractionPeer$", "--", "interaction-peer", marker}} + manage_tentacle(t, runtime, "add", entry.ID, &entry) + waitTentacle(t, func() bool { + snapshot := runtime.Snapshot() + return len(snapshot.Entries) == 1 && snapshot.Entries[0].State == "active" + }) + before, _ := os.ReadFile(path) + snapshot := runtime.Snapshot() + if len(snapshot.Entries[0].Shortcuts) != 1 || snapshot.Entries[0].Shortcuts[0] != "Desktop: F9 (Interactive probe)" { + t.Fatal(snapshot) + } + snapshot.Entries[0].Shortcuts[0] = "modified copy" + if runtime.Snapshot().Entries[0].Shortcuts[0] == "modified copy" { + t.Fatal("snapshot shared shortcut storage") + } + worker := &preferencesWorker{tentacles: runtime} + response := worker.manage_tentacle(ctx, preferences.Request{Tentacle: &tentacle.ManagementRequest{Action: "settings", ID: entry.ID}}) + if mode == "no-settings" { + if response.Accepted || response.Message != "No settings for this tentacle." || keys.InternalUISession() != 0 { + t.Fatal(response) + } + return + } + if !response.Accepted || response.Message != "" { + t.Fatal(response) + } + waitTentacle(t, func() bool { return keys.InternalUISession() != 0 }) + sessionID := keys.InternalUISession() + switch mode { + case "settings-save": + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: sessionID, Text: "saved value"}) + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: sessionID, Chord: input.CommandChord{Symbol: 0xff0d}}) + case "settings-cancel": + ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: sessionID, Chord: input.CommandChord{Symbol: 0xff1b}}) + case "settings-disable": + manage_tentacle(t, runtime, "disable", entry.ID, nil) + } + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 && keys.InternalUISession() == 0 }) + if mode != "settings-disable" { + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("opening settings changed registry") + } + } + if mode == "settings-save" { + data, err := os.ReadFile(marker) + if err != nil || len(data) == 0 { + t.Fatal("settings submission was not delivered", err) + } + } + }) + } +} diff --git a/internal/app/tentacle_uninterruptible_test.go b/internal/app/tentacle_uninterruptible_test.go new file mode 100644 index 0000000..8f1fc68 --- /dev/null +++ b/internal/app/tentacle_uninterruptible_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type protectedSpeaker struct{ confirmationSpeaker } + +func (speaker *protectedSpeaker) speak_uninterruptible(ctx context.Context, text string) error { + return speaker.call(ctx, text) +} + +func protected_output_fixture(t *testing.T) (*tentacleOutput, *protectedSpeaker) { + t.Helper() + owner := tentacleOutputFixture(t) + speaker := &protectedSpeaker{confirmationSpeaker{controlledSpeaker{calls: make(chan speechCall, 20)}}} + owner.speech.speaker = speaker + owner.speech.running = false + if err := owner.speech.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { owner.speech.Stop(context.Background()) }) + return owner, speaker +} + +func TestTentacleUninterruptibleSurvivesInputAndFocusUntilEnd(t *testing.T) { + owner, speaker := protected_output_fixture(t) + ticket := owner.Capture(t.Context()) + owner.Present(ticket, tentaclewire.Presentation{Speech: "Hello world!", Braille: "Hello world!", Uninterruptible: true}, "tentacle/test/hello") + // Protection begins on admission, before the backend has accepted SPEAK. + if owner.speech.InterruptForInput(input.Event{Timestamp: 101, Pressed: true}) { + t.Fatal("queued greeting was interrupted") + } + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + greeting := expectSpeechCall(t, &speaker.controlledSpeaker, "Hello world!") + owner.speech.Interrupt() + owner.speech.InterruptForUser() + owner.speech.ProtectCommand(102) + owner.Capture(t.Context()) // Another command and ordinary focus retirement. + owner.gate.retireTentacleOutput() + if owner.speech.InterruptForInput(input.Event{Timestamp: 103, Pressed: true}) { + t.Fatal("active greeting was interrupted") + } + owner.speech.Enqueue(speechRequest{Text: "Focused control"}) + owner.speech.mutex.Lock() + if len(owner.speech.pending) != 1 || !owner.speech.pending[0].Append || !owner.speech.has_uninterruptible_locked() { + t.Error("core output did not queue behind protected speech") + } + owner.speech.mutex.Unlock() + select { + case call := <-speaker.calls: + t.Fatalf("backend was interrupted before END: %q", call.text) + default: + } + close(greeting.done) // Actual END, not the SPEAK acknowledgement. + focus := expectSpeechCall(t, &speaker.controlledSpeaker, "Focused control") + if !owner.speech.InterruptForInput(input.Event{Timestamp: 104, Pressed: true}) { + t.Fatal("protection leaked after completion") + } + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + close(focus.done) +} + +func TestTentacleUninterruptibleStillRevokes(t *testing.T) { + for _, action := range []string{"session", "disable", "lock", "handoff", "speech off", "mode", "stop", "lifetime"} { + t.Run(action, func(t *testing.T) { + owner, speaker := protected_output_fixture(t) + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + ticket := owner.Capture(parent) + owner.Present(ticket, tentaclewire.Presentation{Speech: "Protected greeting", Uninterruptible: true}, "tentacle/test/hello") + owner.speech.mutex.Lock() + request := owner.speech.pending[0] + deadline, bounded := request.context.Deadline() + owner.speech.mutex.Unlock() + if !bounded || time.Until(deadline) > uninterruptibleSpeechTimeout { + t.Fatal("unbounded protected speech") + } + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + _ = expectSpeechCall(t, &speaker.controlledSpeaker, "Protected greeting") + switch action { + case "session": + cancel() + case "disable": + owner.Stop() + case "lock": + owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille) + case "handoff": + owner.gate.SetSuspended(true) + case "speech off": + owner.speech.SetEnabled(false) + case "mode": + owner.speech.cycle_speech_mode() + case "stop": + if err := owner.speech.Stop(t.Context()); err != nil { + t.Fatal(err) + } + case "lifetime": + // Retire the admitted lifetime deterministically. Its actual + // deadline is checked above, without a 30-second test sleep. + request.cancel() + } + if request.context.Err() == nil { + t.Fatal("protected lifetime survived revocation") + } + if action == "stop" { + return + } + // Complete cancellation calls, then prove delivery resumes. Off + // and disabled modes are restored only after their barrier drains. + owner.speech.SetEnabled(true) + owner.speech.mutex.Lock() + owner.speech.mode = speechModeSpeech + owner.speech.secure = false + owner.speech.mutex.Unlock() + owner.speech.Enqueue(speechRequest{Text: "Resumed"}) + for { + call := expect_next_speech_call(t, &speaker.controlledSpeaker) + close(call.done) + if call.text == "Resumed" { + return + } + if call.text != "cancel" { + t.Fatalf("unexpected output %q", call.text) + } + } + }) + } +} + +func expect_next_speech_call(t *testing.T, speaker *controlledSpeaker) speechCall { + t.Helper() + select { + case call := <-speaker.calls: + return call + case <-time.After(time.Second): + t.Fatal("speech failed to resume") + return speechCall{} + } +} + +func TestTentacleUninterruptibleCannotResurrectStaleReply(t *testing.T) { + for _, action := range []string{"input", "focus", "new speech", "revoked", "lock", "handoff"} { + t.Run(action, func(t *testing.T) { + owner := tentacleOutputFixture(t) + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + ticket := owner.Capture(parent) + switch action { + case "input": + owner.speech.InterruptForUser() + case "focus": + owner.Retire() + case "new speech": + owner.speech.Enqueue(speechRequest{Text: "Core"}) + case "revoked": + cancel() + case "lock": + owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille) + case "handoff": + owner.gate.SetSuspended(true) + } + owner.Present(ticket, tentaclewire.Presentation{Speech: "Stale", Uninterruptible: true}, "tentacle/test/hello") + for _, request := range owner.speech.pending { + if request.Text == "Stale" || request.Uninterruptible { + t.Fatal("protected option bypassed stale result check") + } + } + }) + } +} + +func TestTentacleProtectedStopPreservesCoreQueue(t *testing.T) { + owner, speaker := protected_output_fixture(t) + owner.Present(owner.Capture(t.Context()), tentaclewire.Presentation{Speech: "Greeting", Uninterruptible: true}, "tentacle/test/hello") + close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) + _ = expectSpeechCall(t, &speaker.controlledSpeaker, "Greeting") + owner.speech.Enqueue(speechRequest{Text: "Core output"}) + owner.Stop() + for { + call := expect_next_speech_call(t, &speaker.controlledSpeaker) + close(call.done) + if call.text == "Core output" { + return + } + if call.text != "cancel" { + t.Fatalf("unexpected output %q", call.text) + } + } +} + +func TestTentacleProtectedModesAndUnsupportedHardware(t *testing.T) { + for _, mode := range []speechMode{speechModeBeeps, speechModeOff} { + owner := tentacleOutputFixture(t) + owner.speech.mode = mode + beeps := 0 + owner.speech.beep = func(context.Context) error { beeps++; return nil } + owner.Present(owner.Capture(t.Context()), tentaclewire.Presentation{ + Speech: "Greeting", Braille: "Display", Uninterruptible: true, + }, "tentacle/test/hello") + request, ok := owner.speech.next() + if !ok { + t.Fatal("no protected presentation") + } + if err := owner.speech.deliver(request.context, request); err != nil { + t.Fatal(err) + } + completeSpeechRequest(request, nil) + if (beeps == 1) != (mode == speechModeBeeps) || owner.braille.current().Text != "Display" { + t.Fatal("protected presentation bypassed core output preferences") + } + } + // Hardware SpeakAndWait reports transmission rather than audible END. It + // must not be treated as a completion-capable protected-speech backend. + configured := &configuredSpeaker{active: &hardwareSpeaker{}} + if err := configured.speak_uninterruptible(t.Context(), "Greeting"); err == nil { + t.Fatal("hardware backend claimed audible completion") + } +} diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go new file mode 100644 index 0000000..2472871 --- /dev/null +++ b/internal/app/tentacles.go @@ -0,0 +1,424 @@ +package app + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/shortcutui" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type tentacleInput interface { + SetTentacleBindings(context.Context, []input.BindingOverride) error + TentacleBindings() []input.BindingOverride +} + +type tentacleCommand struct { + session *tentacle.Session + entryID string + id, title, description string + presentation bool + interactive bool + settings bool +} + +// tentacleRuntime owns local sessions for one reader lifetime. Its worker owns +// native publication; mutex protects dispatch snapshots and immediate disable. +// Only explicitly granted presentation commands reach the core output owner. +type tentacleRuntime struct { + forms tentacleForms + focusedItem func(context.Context) (tentaclewire.Item, error) + interactionSlots chan struct{} + registry *tentacle.Registry + requests chan tentacleManagementCall + snapshot tentacle.Snapshot + guardianProgram []string // Private fixture seam; production uses this executable. + output *tentacleOutput + logger *slog.Logger + input tentacleInput + path, parent string + newManager func() (*tentacle.Manager, error) // private fixture seam + fetchRepository func(context.Context, string) (tentacle.Repository, error) // private fixture seam + preparePackage func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) // private fixture seam + repositoryBusy bool + mutex sync.Mutex + commands map[input.Command]tentacleCommand + sessions []*tentacle.Session + disabled bool + cancel context.CancelFunc + readerContext context.Context + lifecycleContext context.Context + done chan struct{} + failures chan error + announcements chan string + stopError error + results sync.WaitGroup + resultSlots chan struct{} +} + +func newTentacleRuntime(logger *slog.Logger, commands commandListener, path, parent string) *tentacleRuntime { + owner, _ := commands.(tentacleInput) + return &tentacleRuntime{logger: logger, input: owner, path: path, parent: parent, + commands: make(map[input.Command]tentacleCommand), failures: make(chan error, 1), announcements: make(chan string, 1), interactionSlots: make(chan struct{}, 1), resultSlots: make(chan struct{}, 64)} +} + +func (runtime *tentacleRuntime) Start(ctx context.Context) error { + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + runtime.readerContext = ctx + ctx, runtime.cancel = context.WithCancel(ctx) + runtime.lifecycleContext = ctx + runtime.done = make(chan struct{}) + go func() { + defer close(runtime.done) + run := runtime.run + if runtime.registry != nil { + run = runtime.run_managed + } + if err := run(ctx); err != nil { + runtime.logger.Warn("tentacle startup failed", "error", err) + runtime.report("Tentacles could not start.") + } + }() + return nil +} + +func (runtime *tentacleRuntime) Disable() { + if runtime == nil { + return + } + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + runtime.disabled = true + runtime.output.Stop() + clear(runtime.commands) + for _, session := range runtime.sessions { + session.Disable() + } + if runtime.cancel != nil { + runtime.cancel() + } +} + +func (runtime *tentacleRuntime) Stop(ctx context.Context) error { + if runtime == nil { + return nil + } + runtime.Disable() + if runtime.done == nil { + return nil + } + select { + case <-runtime.done: + return runtime.stopError + case <-ctx.Done(): + return ctx.Err() + } +} + +func (runtime *tentacleRuntime) run(ctx context.Context) error { + if runtime.input == nil { + return errors.New("tentacles require native binding support") + } + specs, err := tentacle.LoadLocalSpecs(runtime.path) + if err != nil || len(specs) == 0 { + return err + } + var manager *tentacle.Manager + if runtime.newManager != nil { + manager, err = runtime.newManager() + } else { + parent := runtime.parent + if parent == "auto" { + parent, err = tentacle.DelegatedParent() + } + if err == nil { + manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, parent, tentacle.DefaultResourceLimits()) + } + } + if err != nil { + return err + } + var watchers sync.WaitGroup + defer func() { + runtime.Disable() + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + bindingError := runtime.input.SetTentacleBindings(cleanup, nil) + if runtime.readerContext.Err() != nil { + bindingError = nil + } + runtime.stopError = errors.Join(bindingError, manager.Shutdown(cleanup)) + watchers.Wait() + runtime.results.Wait() + if runtime.stopError != nil && runtime.readerContext.Err() == nil { + select { + case runtime.failures <- runtime.stopError: + default: + } + } + }() + wake := make(chan struct{}, 1) + var current *tentacle.Session + var ready <-chan struct{} + var active []*tentacle.Session + index := 0 + for { + if ctx.Err() != nil { + return nil + } + if current == nil && index < len(specs) { + spec := specs[index] + index++ + session, startError := manager.Start(ctx, spec) + if startError != nil { + runtime.logger.Warn("start local tentacle", "tentacle", spec.ID, "error", startError) + runtime.report("A tentacle could not start.") + continue + } + runtime.mutex.Lock() + runtime.sessions = append(runtime.sessions, session) + if runtime.disabled { + session.Disable() + } + runtime.mutex.Unlock() + current, ready = session, session.Ready() + watchers.Add(1) + go func() { + defer watchers.Done() + select { + case <-session.Revoked(): + case <-ctx.Done(): + } + runtime.output.RetireRevoked() + select { + case wake <- struct{}{}: + default: + } + }() + } + select { + case <-ctx.Done(): + return nil + case <-wake: + retained := active[:0] + for _, session := range active { + if state, failure := session.Status(); state == tentacle.Active { + retained = append(retained, session) + } else if state == tentacle.Failed { + runtime.logger.Warn("local tentacle failed", "session", session.Identity(), "error", failure) + runtime.report("A tentacle stopped unexpectedly.") + } + } + active = retained + runtime.publishCommands(active) + if err := runtime.bind(ctx, active); err != nil { + if ctx.Err() != nil { + return nil + } + runtime.failures <- err // Stop the reader if stale grabs cannot be removed. + return err + } + case <-ready: + session := current + current, ready = nil, nil + if state, failure := session.Status(); state != tentacle.Ready { + runtime.logger.Warn("local tentacle handshake failed", "error", failure) + runtime.report("A tentacle could not start.") + continue + } + candidate := append(append([]*tentacle.Session(nil), active...), session) + if err := runtime.bind(ctx, candidate); err != nil { + session.Disable() + runtime.logger.Warn("local tentacle bindings rejected", "error", err) + runtime.report("A tentacle could not acquire its keyboard shortcuts.") + continue + } + if err := session.Activate(); err != nil { + session.Disable() + if restore := runtime.bind(ctx, active); restore != nil && ctx.Err() == nil { + runtime.failures <- restore + return restore + } + continue + } + active = candidate + runtime.publishCommands(active) + runtime.logger.Info("local tentacle active", "session", session.Identity()) + } + } +} + +func (runtime *tentacleRuntime) bind(ctx context.Context, sessions []*tentacle.Session) error { + var bindings []input.BindingOverride + for _, session := range sessions { + for _, command := range session.Commands() { + for _, binding := range command.Bindings { + layout := input.DesktopKeyboardLayout + if binding.Layout == "laptop" { + layout = input.LaptopKeyboardLayout + } + bindings = append(bindings, input.BindingOverride{Layout: layout, Scope: binding.Scope, + Command: input.Command("tentacle/" + session.Identity() + "/" + command.ID), Keys: []string{binding.Key}}) + } + } + } + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + return runtime.input.SetTentacleBindings(ctx, bindings) +} + +func (runtime *tentacleRuntime) publishCommands(sessions []*tentacle.Session) { + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + clear(runtime.commands) + if runtime.disabled { + return + } + for _, session := range sessions { + for _, command := range session.Commands() { + runtime.commands[input.Command("tentacle/"+session.Identity()+"/"+command.ID)] = tentacleCommand{ + session: session, entryID: session.TentacleID(), id: command.ID, title: command.Title, + description: command.Description, presentation: command.Presentation, + interactive: command.Interaction, settings: command.Settings, + } + } + } +} + +// Invoke never waits for a child or native binding transaction. Every result +// worker has a bounded slot and joins before lifecycle shutdown completes. +func (runtime *tentacleRuntime) Invoke(ctx context.Context, command input.Command) bool { + if runtime == nil || !strings.HasPrefix(string(command), "tentacle/") { + return false + } + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + registered, ok := runtime.commands[command] + if !ok || runtime.disabled { + return true + } + if err := runtime.invokeLocked(ctx, command, registered); err != nil { + runtime.logger.Debug("tentacle command rejected", "command", command, "error", err) + } + return true +} + +// Caller holds runtime.mutex so shutdown cannot race worker admission. +func (runtime *tentacleRuntime) invokeLocked(ctx context.Context, command input.Command, registered tentacleCommand) error { + select { + case runtime.resultSlots <- struct{}{}: + default: + return tentacle.ErrBusy + } + result, err := registered.session.Invoke(ctx, registered.id, nil) + if err != nil { + <-runtime.resultSlots + return err + } + var ticket tentacleOutputTicket + if registered.presentation { + ticket = runtime.output.Capture(registered.session.OutputContext()) + } + runtime.results.Add(1) + go func() { + defer runtime.results.Done() + defer func() { <-runtime.resultSlots }() + value := <-result + runtime.logger.Debug("tentacle command completed", "command", command, "request", value.ID, "error", value.Err) + if value.Err != nil || !registered.presentation { + return + } + if registered.interactive { + if ticket.ctx == nil || ticket.ctx.Err() != nil { + return + } + runtime.interact(ctx, registered, value.Value) + return + } + presentation, err := tentaclewire.DecodePresentation(value.Value) + if err != nil { + registered.session.Disable() + runtime.output.RetireRevoked() + runtime.logger.Warn("tentacle returned invalid presentation", "command", command, "error", err) + runtime.report("A tentacle returned an invalid message and was disabled.") + return + } + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + if !runtime.disabled && value.Session == registered.session.Identity() { + runtime.output.Present(ticket, presentation, string(command)) + } + }() + return nil +} + +func (runtime *tentacleRuntime) Describe(chord input.CommandChord, layout input.KeyboardLayout, browse bool) string { + if runtime == nil || runtime.input == nil { + return "" + } + bindings := runtime.input.TentacleBindings() + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + for _, binding := range bindings { + if binding.Layout != layout || binding.Scope == "browse" && !browse { + continue + } + bound, _ := input.ParseCommandChord(binding.Keys[0]) + registered, ok := runtime.commands[binding.Command] + if bound != chord || !ok { + continue + } + if state, _ := registered.session.Status(); state != tentacle.Active { + continue + } + if registered.description != "" { + return registered.description + } + return registered.title + } + return "" +} + +// ShortcutRows uses the acknowledged native assignments, including core remaps. +func (runtime *tentacleRuntime) ShortcutRows(layout input.KeyboardLayout) []shortcutui.Row { + if runtime == nil || runtime.input == nil { + return nil + } + bindings := runtime.input.TentacleBindings() + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + var rows []shortcutui.Row + for _, binding := range bindings { + registered, ok := runtime.commands[binding.Command] + if binding.Layout != layout || !ok { + continue + } + if state, _ := registered.session.Status(); state != tentacle.Active { + continue + } + label := registered.description + if label == "" { + label = registered.title + } + group := "Tentacles" + if binding.Scope == "browse" { + group = "Tentacles in browse mode" + } + rows = append(rows, shortcutui.Row{Group: group, Label: label, Keys: binding.Keys[0]}) + } + return rows +} + +func (runtime *tentacleRuntime) report(message string) { + select { + case runtime.announcements <- message: + default: + } +} diff --git a/internal/app/tentacles_test.go b/internal/app/tentacles_test.go new file mode 100644 index 0000000..9344edf --- /dev/null +++ b/internal/app/tentacles_test.go @@ -0,0 +1,467 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentacle" + sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" +) + +// Subprocess fixture: no reader backend, network, or desktop access. +func TestTentacleReaderPeer(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "reader-peer" { + return + } + marker := os.Args[len(os.Args)-1] + presentation := strings.HasPrefix(filepath.Base(marker), "present") + if filepath.Base(marker) == "stall" { + time.Sleep(time.Minute) + os.Exit(0) + } + err := sdk.Serve(os.Stdin, os.Stdout, []sdk.Command{{ID: "probe", Title: "Probe command", Description: "Run the private reader probe.", + Presentation: presentation, + Bindings: []sdk.Binding{{Layout: "desktop", Scope: "global", Key: "F9"}}, + Handle: func(ctx context.Context, _ json.RawMessage) (any, error) { + if filepath.Base(marker) == "crash" { + os.Exit(3) + } + group, _ := os.ReadFile("/proc/self/cgroup") + if err := os.WriteFile(marker, group, 0600); err != nil { + return nil, err + } + if filepath.Base(marker) == "present-delay" { + for { + if _, err := os.Stat(marker + ".release"); err == nil { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Millisecond): + } + } + } + if filepath.Base(marker) == "present-invalid" { + return map[string]any{"speech": "Private text", "unknown": true}, nil + } + if presentation { + return sdk.Presentation{Speech: "Hello, é猫😀é", Braille: "Display é猫😀é"}, nil + } + return nil, nil + }, + }}) + if err != nil { + os.Exit(1) + } + os.Exit(0) +} + +type tentacleBindingRecorder struct { + silentCommands + mutex sync.Mutex + bindings []input.BindingOverride + reject bool + removeError error +} + +func (recorder *tentacleBindingRecorder) SetTentacleBindings(_ context.Context, bindings []input.BindingOverride) error { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + if len(bindings) == 0 && recorder.removeError != nil { + return recorder.removeError + } + if recorder.reject && len(bindings) > 0 { + return errors.New("fixture grab collision") + } + recorder.bindings = input.CloneBindingOverrides(bindings) + return nil +} +func (recorder *tentacleBindingRecorder) TentacleBindings() []input.BindingOverride { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + return input.CloneBindingOverrides(recorder.bindings) +} + +func readerTentacleFixture(t *testing.T, mode string, contained bool) (*tentacleRuntime, *tentacleBindingRecorder, string) { + t.Helper() + directory := t.TempDir() + marker := filepath.Join(directory, mode) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + specs := []tentacle.Spec{{ID: "probe", Presentation: strings.HasPrefix(mode, "present"), Program: []string{executable, "-test.run=^TestTentacleReaderPeer$", "--", "reader-peer", marker}}} + data, _ := json.Marshal(specs) + path := filepath.Join(directory, "launch.json") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + bindings := &tentacleBindingRecorder{reject: mode == "reject"} + runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, path, "auto") + if !contained { + runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) } + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil && !errors.Is(err, bindings.removeError) { + t.Error(err) + } + }) + return runtime, bindings, marker +} + +func waitTentacle(t *testing.T, check func() bool) { + t.Helper() + deadline := time.Now().Add(6 * time.Second) + for !check() { + if time.Now().After(deadline) { + t.Fatal("tentacle condition timed out") + } + time.Sleep(time.Millisecond) + } +} + +func TestReaderTentacleDispatchLearnDisable(t *testing.T) { + runtime, bindings, marker := readerTentacleFixture(t, "called", false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + settings := &liveSettings{} + settings.Store(config.DefaultSettings()) + speech := &learnSpeechRecorder{} + braille := &learnBrailleRecorder{} + presenter := &learnModePresenter{tentacles: runtime, live: settings, speech: speech, braille: braille, active: true} + presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: f9, KeyName: "F9"}) + if speech.texts[len(speech.texts)-1] != "Run the private reader probe." || braille.texts[len(braille.texts)-1] != "Run the private reader probe." { + t.Fatal("learn description missing") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("learn mode executed command") + } + rows := runtime.ShortcutRows(input.DesktopKeyboardLayout) + if len(rows) != 1 || rows[0].Keys != "F9" { + t.Fatalf("shortcut help: %+v", rows) + } + command := bindings.TentacleBindings()[0].Command + if !runtime.Invoke(context.Background(), command) { + t.Fatal("command not handled") + } + waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) + runtime.Disable() + if runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" { + t.Fatal("disabled description remains") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil { + t.Fatal(err) + } + if len(bindings.TentacleBindings()) != 0 { + t.Fatal("disabled native bindings remain") + } + if len(runtime.resultSlots) != 0 { + t.Fatal("result workers remain") + } + if err := os.Remove(marker); err != nil { + t.Fatal(err) + } + runtime.Invoke(context.Background(), command) + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("stale command executed") + } +} + +func TestReaderTentacleStartupDoesNotWaitForPeer(t *testing.T) { + runtime, bindings, _ := readerTentacleFixture(t, "stall", false) + started := make(chan error, 1) + go func() { started <- runtime.Start(context.Background()) }() + select { + case err := <-started: + if err != nil { + t.Fatal(err) + } + case <-time.After(200 * time.Millisecond): + t.Fatal("reader startup waited for handshake") + } + waitTentacle(t, func() bool { runtime.mutex.Lock(); defer runtime.mutex.Unlock(); return len(runtime.sessions) == 1 }) + runtime.Disable() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil { + t.Fatal(err) + } + if len(bindings.TentacleBindings()) != 0 { + t.Fatal("stalled peer published grabs") + } +} + +func TestReaderTentaclePresentation(t *testing.T) { + for _, mode := range []string{"present", "present-invalid", "present-delay"} { + t.Run(mode, func(t *testing.T) { + runtime, bindings, marker := readerTentacleFixture(t, mode, false) + owner := tentacleOutputFixture(t) + runtime.output = owner + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + command := bindings.TentacleBindings()[0].Command + runtime.Invoke(context.Background(), command) + waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) + if mode == "present-delay" { + // Same focused control can change while the child computes. A + // consumed accessibility event retires the command's revision. + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), discardSpeech{}, adapter, nil, nil, 0, owner.live, nil, owner.gate, nil) + }() + adapter.events <- atspi.NewEvent("object:text-changed:insert", atspi.Snapshot{}) + waitTentacle(t, func() bool { owner.mutex.Lock(); defer owner.mutex.Unlock(); return owner.current == nil }) + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker+".release", nil, 0600); err != nil { + t.Fatal(err) + } + } + waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) + owner.speech.mutex.Lock() + pending := append([]speechRequest(nil), owner.speech.pending...) + owner.speech.mutex.Unlock() + if mode == "present" { + if len(pending) != 1 || pending[0].Text != "Hello, é猫😀é" || owner.braille.current().Text != "Display é猫😀é" { + t.Fatal("reader did not present the command result") + } + runtime.Disable() + if pending[0].context.Err() == nil || owner.braille.current().Text != "Core focus" { + t.Fatal("disable left accepted output live") + } + } else { + if len(pending) != 0 || owner.braille.current().Text != "Core focus" { + t.Fatal("invalid/stale reply reached output") + } + if mode == "present-invalid" { + waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) == 0 }) + select { + case <-runtime.announcements: + default: + t.Fatal("invalid presentation was not reported") + } + } + } + }) + } +} + +func TestReaderTentacleCommandLoopHandoffAndEmergency(t *testing.T) { + runtime, bindings, marker := readerTentacleFixture(t, "called", false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + command := bindings.TentacleBindings()[0].Command + commands := &fakeCommands{commands: make(chan input.CommandEvent, 4)} + gate := newFocusGate(commands) + gate.SetSuspended(true) + barrier := make(chan struct{}, 1) + speech := &learnSpeechRecorder{} + braille := &learnBrailleRecorder{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, focusGate: gate, + tentacles: runtime, speechOutput: discardSpeech{}, stopRun: cancel, + learn: &learnModePresenter{speech: speech, braille: braille}, onPreferences: func() { barrier <- struct{}{} }} + done := make(chan error, 1) + go func() { done <- worker.run(ctx) }() + commands.commands <- input.CommandEvent{Command: command} + commands.commands <- input.CommandEvent{Command: input.Preferences} + select { + case <-barrier: + case <-time.After(time.Second): + t.Fatal("command loop stalled") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("handoff invoked tentacle") + } + gate.SetSuspended(false) + commands.commands <- input.CommandEvent{Command: command} + waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) + commands.commands <- input.CommandEvent{Command: input.DisableTentacles} + commands.commands <- input.CommandEvent{Command: input.Quit} + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("emergency disable blocked Quit") + } + if len(speech.texts) != 1 || speech.texts[0] != "Tentacles disabled." || len(braille.texts) != 1 { + t.Fatalf("disable feedback: %v %v", speech.texts, braille.texts) + } + if runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" { + t.Fatal("emergency disable retained command") + } +} + +func TestReaderTentacleInvalidDelegationDoesNotLaunch(t *testing.T) { + runtime, bindings, marker := readerTentacleFixture(t, "called", true) + runtime.parent = t.TempDir() + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-runtime.done: + case <-time.After(time.Second): + t.Fatal("invalid delegation blocked startup") + } + if len(runtime.sessions) != 0 || len(bindings.TentacleBindings()) != 0 { + t.Fatal("invalid delegation admitted peer") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("uncontained fallback ran") + } + select { + case message := <-runtime.announcements: + if message != "Tentacles could not start." { + t.Fatal(message) + } + default: + t.Fatal("startup failure has no accessible status") + } +} + +func TestReaderTentacleCleanupFailureReachesReader(t *testing.T) { + runtime, bindings, _ := readerTentacleFixture(t, "called", false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + want := errors.New("fixture native removal failure") + bindings.mutex.Lock() + bindings.removeError = want + bindings.mutex.Unlock() + runtime.Disable() + select { + case err := <-runtime.failures: + if !errors.Is(err, want) { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("cleanup failure did not reach command loop") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := runtime.Stop(ctx); !errors.Is(err, want) { + t.Fatalf("cleanup falsely succeeded: %v", err) + } +} + +func TestReaderTentacleFailedGrabAndCrashRevoke(t *testing.T) { + for _, mode := range []string{"reject", "crash"} { + t.Run(mode, func(t *testing.T) { + runtime, bindings, _ := readerTentacleFixture(t, mode, false) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + if mode == "crash" { + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) + } + waitTentacle(t, func() bool { + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + if len(runtime.sessions) != 1 { + return false + } + select { + case <-runtime.sessions[0].Revoked(): + return true + default: + return false + } + }) + waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) == 0 }) + if runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" { + t.Fatal("revoked command still described") + } + }) + } +} + +func TestNativeDelegatedReaderTentacle(t *testing.T) { + if os.Getenv("CTHULHU_TENTACLE_DELEGATED") != "1" { + t.Skip("requires the delegated reader launcher") + } + runtime, bindings, marker := readerTentacleFixture(t, "called", true) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) + waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) + group, _ := os.ReadFile(marker) + parent, err := tentacle.DelegatedParent() + if err != nil { + t.Fatal(err) + } + expected := strings.TrimPrefix(parent, "/sys/fs/cgroup") + "/cthulhu-tentacles-" + if !strings.Contains(string(group), expected) || strings.Contains(string(group), "/reader/") { + t.Fatalf("child outside limited sibling subtree: %s", group) + } + t.Logf("contained command membership: %s", group) +} + +func TestNativeDelegatedReaderTentaclePresentation(t *testing.T) { + if os.Getenv("CTHULHU_TENTACLE_DELEGATED") != "1" { + t.Skip("requires the delegated reader launcher") + } + runtime, bindings, _ := readerTentacleFixture(t, "present", true) + owner := tentacleOutputFixture(t) + runtime.output = owner + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + f9, _ := input.ParseCommandChord("F9") + waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" }) + runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) + waitTentacle(t, func() bool { + owner.speech.mutex.Lock() + defer owner.speech.mutex.Unlock() + return len(owner.speech.pending) == 1 && owner.braille.current().Text == "Display é猫😀é" + }) + if owner.braille.current().Text != "Display é猫😀é" { + t.Fatal("contained command did not present braille") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := runtime.Stop(ctx); err != nil { + t.Fatal(err) + } + if owner.braille.current().Text != "Core focus" { + t.Fatal("shutdown left tentacle output") + } +} diff --git a/internal/app/terminal_events_test.go b/internal/app/terminal_events_test.go new file mode 100644 index 0000000..d9b1ef8 --- /dev/null +++ b/internal/app/terminal_events_test.go @@ -0,0 +1,212 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") + output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Command output") + deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "old prompt") + deletion.Detail1 = 12 + prompt := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new prompt") + prompt.Detail1 = 12 + adapter.events <- output + adapter.events <- deletion + adapter.events <- prompt + + for _, expected := range []string{"Command output", "new prompt"} { + select { + case text := <-speech.spoken: + if text != expected { + t.Fatalf("spoken text = %q, want %q", text, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for terminal speech %q", expected) + } + } + select { + case text := <-speech.spoken: + t.Fatalf("terminal redraw deletion was presented as %q", text) + case <-time.After(2 * terminalMutationDebounce): + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestProcessEventsPresentsStandaloneTerminalDeletion(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") + select { + case text := <-speech.spoken: + if text != "x" { + t.Fatalf("spoken deletion = %q, want %q", text, "x") + } + case <-time.After(time.Second): + t.Fatal("standalone terminal deletion was not presented") + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestProcessEventsDoesNotCoalesceTerminalChangesAtDifferentOffsets(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") + deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") + deletion.Detail1 = 12 + insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "y") + insertion.Detail1 = 13 + adapter.events <- deletion + adapter.events <- insertion + + for _, expected := range []string{"x", "y"} { + select { + case text := <-speech.spoken: + if text != expected { + t.Fatalf("spoken text = %q, want %q", text, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for terminal speech %q", expected) + } + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestProcessEventsCoalescesTerminalFocusWithFollowingInsertion(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 2)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, time.Hour) + close(completed) + }() + + terminal := atspi.NewSnapshot("/accessible/terminal", "Terminal", "terminal", "", "Old prompt") + focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) + focused.Detail1 = 1 + adapter.events <- focused + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "New prompt") + + select { + case text := <-speech.spoken: + if text != "New prompt" { + t.Fatalf("speech = %q, want only following insertion", text) + } + case <-time.After(time.Second): + t.Fatal("following terminal insertion was not presented") + } + select { + case text := <-speech.spoken: + t.Fatalf("redundant terminal focus was presented as %q", text) + default: + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + close(completed) + }() + + terminal := atspi.NewSnapshot("/accessible/terminal", "Terminal", "terminal", "", "Prompt") + terminal.ApplicationName = "TestTerminal" + focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) + focused.Detail1 = 1 + adapter.events <- focused + select { + case text := <-speech.spoken: + if text != "Terminal, terminal, Prompt" { + t.Fatalf("terminal focus speech = %q", text) + } + case <-time.After(time.Second): + t.Fatal("terminal focus was not presented after debounce") + } + + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} diff --git a/internal/app/test_helpers_test.go b/internal/app/test_helpers_test.go new file mode 100644 index 0000000..03a57c3 --- /dev/null +++ b/internal/app/test_helpers_test.go @@ -0,0 +1,247 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "sync" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +type fakeSpeaker struct { + connectError error + connected bool + speakError error + spoken chan string + closed bool + canceled bool + canceledOnClose bool +} + +func (speaker *fakeSpeaker) Connect(context.Context) error { + speaker.connected = true + return speaker.connectError +} + +func (speaker *fakeSpeaker) Speak(_ context.Context, text string) error { + if speaker.speakError != nil { + return speaker.speakError + } + speaker.spoken <- text + return nil +} + +func (speaker *fakeSpeaker) Cancel(context.Context) error { speaker.canceled = true; return nil } + +func (speaker *fakeSpeaker) Close(context.Context) error { + speaker.canceledOnClose = speaker.canceled + speaker.closed = true + return nil +} + +type fakeAdapter struct { + startError error + stopError error + started bool + stopped bool + events chan atspi.Event + tree *atspi.AccessibleTree + captureErr error + capture func(context.Context) (*atspi.AccessibleTree, error) + captureSoundGeometry bool +} + +type fakeSpeechQueue struct { + spoken chan string + requests chan speechRequest + interrupt chan struct{} +} + +type fixedInputActivity struct{ activity input.Activity } + +func (source fixedInputActivity) RecentInput() input.Activity { return source.activity } + +type sequencedInputActivity struct { + mutex sync.Mutex + sequence uint64 +} + +func (source *sequencedInputActivity) RecentInput() input.Activity { + source.mutex.Lock() + defer source.mutex.Unlock() + return input.Activity{Sequence: source.sequence} +} + +func (source *sequencedInputActivity) set(sequence uint64) { + source.mutex.Lock() + source.sequence = sequence + source.mutex.Unlock() +} + +func (queue *fakeSpeechQueue) Enqueue(request speechRequest) { + if queue.requests != nil { + queue.requests <- request + } + queue.spoken <- request.Text +} + +func (queue *fakeSpeechQueue) Interrupt() { + if queue.interrupt != nil { + queue.interrupt <- struct{}{} + } +} + +func (adapter *fakeAdapter) Events() <-chan atspi.Event { return adapter.events } + +func (adapter *fakeAdapter) Overflow() uint64 { return 0 } + +func (adapter *fakeAdapter) SetCaptureSoundGeometry(enabled bool) { + adapter.captureSoundGeometry = enabled +} + +func (adapter *fakeAdapter) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) { + if adapter.capture != nil { + return adapter.capture(ctx) + } + return adapter.tree, adapter.captureErr +} + +func (adapter *fakeAdapter) CaptureReviewWindow(ctx context.Context, _ atspi.ObjectID) (*atspi.AccessibleTree, error) { + return adapter.CaptureDesktop(ctx) +} + +func (adapter *fakeAdapter) CaptureDocument(ctx context.Context, _ atspi.ObjectID) (*atspi.AccessibleTree, error) { + return adapter.CaptureDesktop(ctx) +} + +func (adapter *fakeAdapter) CaptureDocumentText(ctx context.Context, _ atspi.ObjectID) (*atspi.AccessibleTree, error) { + return adapter.CaptureDesktop(ctx) +} + +func (adapter *fakeAdapter) Start(context.Context) error { + adapter.started = true + return adapter.startError +} + +func (adapter *fakeAdapter) Stop(context.Context) error { + adapter.stopped = true + return adapter.stopError +} + +type fakeInputListener struct { + events chan input.Event + modifierKeycodes input.ModifierKeycodes + startError error + started bool + stopped bool +} + +func (listener *fakeInputListener) Events() <-chan input.Event { + return listener.events +} + +func (listener *fakeInputListener) ModifierKeycodes() input.ModifierKeycodes { + return listener.modifierKeycodes +} + +func (listener *fakeInputListener) Overflow() uint64 { return 0 } + +func (listener *fakeInputListener) Start(context.Context) error { + listener.started = true + return listener.startError +} + +func (listener *fakeInputListener) Stop(context.Context) error { + listener.stopped = true + return nil +} + +func contains_string(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +type eventDiagnosticsWriter struct { + started chan struct{} + startOnce sync.Once + canceled chan struct{} + cancelOnce sync.Once +} + +func (writer *eventDiagnosticsWriter) Write([]byte) (int, error) { + writer.startOnce.Do(func() { close(writer.started) }) + <-writer.canceled + return 0, nil +} + +func (writer *eventDiagnosticsWriter) Cancel() error { + writer.cancelOnce.Do(func() { close(writer.canceled) }) + return nil +} + +func (writer *eventDiagnosticsWriter) Close() error { return nil } + +type silentSound struct{} + +type rejectingSound struct{ silentSound } + +type recordingSound struct { + started chan struct{} + stopped bool + stopError error +} + +func (sound *recordingSound) Start(context.Context) error { + close(sound.started) + return nil +} + +func (sound *recordingSound) Stop(context.Context) error { + sound.stopped = true + return sound.stopError +} + +func (sound *recordingSound) Enqueue(sound.Cue) bool { return true } + +func (sound *recordingSound) EnqueueBatch([]sound.Cue) bool { return true } + +func (sound *recordingSound) Available(string) bool { return true } + +type silentCommands struct{} + +type fakeCommands struct { + commands chan input.CommandEvent + lockStates chan input.LockState +} + +func (commands *fakeCommands) Commands() <-chan input.CommandEvent { return commands.commands } + +func (commands *fakeCommands) LockStates() <-chan input.LockState { return commands.lockStates } + +func (commands *fakeCommands) Failures() <-chan error { return nil } + +func (commands *fakeCommands) Start(context.Context) error { return nil } + +func (commands *fakeCommands) Stop(context.Context) error { return nil } diff --git a/internal/app/text_selection.go b/internal/app/text_selection.go new file mode 100644 index 0000000..f854a83 --- /dev/null +++ b/internal/app/text_selection.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// textSelection tracks only the focused text object. Its cached content is +// bounded by the native snapshot and is retired when focus changes. +type textSelection struct { + id atspi.ObjectID + start, end int + text string +} + +const selectionFragmentLimit = 120 +const lineSelectionFragmentLimit = 4096 + +func (selection *textSelection) reset(snapshot atspi.Snapshot) { + selection.id = snapshot.ID() + selection.start, selection.end = snapshot.SelectionStart, snapshot.SelectionEnd + selection.text = snapshot.SelectionText +} + +func (selection *textSelection) changed(snapshot atspi.Snapshot) string { + return selection.changedWithLimit(snapshot, selectionFragmentLimit) +} + +func (selection *textSelection) changedWithLimit(snapshot atspi.Snapshot, fragmentLimit int) string { + start, end := snapshot.SelectionStart, snapshot.SelectionEnd + if selection.id != snapshot.ID() { + selection.reset(snapshot) + return "" + } + oldStart, oldEnd, oldText := selection.start, selection.end, selection.text + selection.reset(snapshot) + if start == oldStart && end == oldEnd && oldText == snapshot.SelectionText { + return "" + } + if end <= start { + if oldEnd > oldStart { + return "selection removed" + } + return "" + } + if oldEnd <= oldStart { + return selectionFragment(snapshot.SelectionText, "selected", fragmentLimit) + } + var changes []string + if start > oldStart { + changes = append(changes, selectionFragment(selectionSlice(oldText, 0, min(oldEnd, start)-oldStart), "unselected", fragmentLimit)) + } + if end < oldEnd { + changes = append(changes, selectionFragment(selectionSlice(oldText, max(oldStart, end)-oldStart, oldEnd-oldStart), "unselected", fragmentLimit)) + } + if start < oldStart { + changes = append(changes, selectionFragment(selectionSlice(snapshot.SelectionText, 0, min(end, oldStart)-start), "selected", fragmentLimit)) + } + if end > oldEnd { + changes = append(changes, selectionFragment(selectionSlice(snapshot.SelectionText, max(start, oldEnd)-start, end-start), "selected", fragmentLimit)) + } + if len(changes) == 0 { + return "selection changed" + } + return strings.Join(changes, ", ") +} + +func selectionSlice(value string, start, end int) string { + runes := []rune(value) + if start < 0 || end > len(runes) || start >= end { + return "" + } + return string(runes[start:end]) +} + +func selectionFragment(value, change string, limit int) string { + if utf8.RuneCountInString(value) > limit { + return change + } + value = strings.Join(strings.Fields(value), " ") + if value == "" { + return change + } + return value + " " + change +} diff --git a/internal/app/text_selection_test.go b/internal/app/text_selection_test.go new file mode 100644 index 0000000..a66e023 --- /dev/null +++ b/internal/app/text_selection_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/logging" +) + +func TestTextSelectionDiagnosticsIncludeNativeOffsets(t *testing.T) { + var output bytes.Buffer + logger := logging.New(&output, logging.Options{Level: slog.LevelDebug}) + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 4)} + entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Focused: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + entry.TextCaretKnown, entry.BrailleCaret, entry.BrailleLineStart = true, 13, 10 + entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 11, 13, "猫a" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + close(adapter.events) + if err := processEventsWithInput(context.Background(), logger, queue, adapter, nil, nil, 0, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(&output) + for decoder.More() { + var record map[string]any + if err := decoder.Decode(&record); err != nil { + t.Fatal(err) + } + if record["message"] != "received accessibility event" || record["type"] != atspi.TextSelectionChangedEvent { + continue + } + for key, want := range map[string]any{"bus": ":1.7", "source_path": "/entry", "native_caret_known": true, + "native_caret_offset": float64(13), "native_line_start": float64(10), "selection_start": float64(11), "selection_end": float64(13)} { + if record[key] != want { + t.Fatalf("selection diagnostic %s = %v, want %v", key, record[key], want) + } + } + return + } + t.Fatal("selection event diagnostics missing") +} + +func TestTextSelectionCrossingAnchor(t *testing.T) { + old := atspi.Snapshot{BusName: ":1.7", Path: "/entry", SelectionStart: 0, SelectionEnd: 2, SelectionText: "ab"} + var selection textSelection + selection.reset(old) + newSelection := old + newSelection.SelectionStart, newSelection.SelectionEnd, newSelection.SelectionText = 3, 5, "de" + if got := selection.changed(newSelection); got != "ab unselected, de selected" { + t.Fatalf("crossed anchor = %q", got) + } +} + +func TestTextSelectionSpeaksChangedCharactersAndWords(t *testing.T) { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) + }() + defer func() { cancel(); <-done }() + expect := func(want string) { + t.Helper() + select { + case got := <-queue.requests: + if got.Text != want { + t.Fatalf("speech = %q, want %q", got.Text, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Name: "Message", Editable: true, Focused: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + expect("Message, entry") + + tracker.Observe(input.NewKeyEvent(0, true, "Shift_L")) + tracker.Observe(input.NewKeyEvent(0, true, "Right")) + entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 1, "h" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + expect("h selected") + entry.CaretCharacter = "h" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + select { + case got := <-queue.requests: + t.Fatalf("selection caret spoke twice: %q", got.Text) + case <-time.After(30 * time.Millisecond): + } + + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "Right")) + entry.SelectionEnd, entry.SelectionText = 5, "hello" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + expect("ello selected") + + tracker.Observe(input.NewKeyEvent(0, true, "Left")) + entry.SelectionEnd, entry.SelectionText = 1, "h" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + expect("ello unselected") + + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + select { + case got := <-queue.requests: + t.Fatalf("unchanged selection spoke: %q", got.Text) + case <-time.After(30 * time.Millisecond): + } + tracker.Observe(input.NewKeyEvent(0, false, "Control_L")) + longLine := "h" + strings.Repeat("a", 199) + tracker.Observe(input.NewKeyEvent(0, true, "Down")) + entry.SelectionEnd, entry.SelectionText = 200, longLine + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + expect(strings.Repeat("a", 199) + " selected") + tracker.Observe(input.NewKeyEvent(0, true, "Up")) + entry.SelectionEnd, entry.SelectionText = 1, "h" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + expect(strings.Repeat("a", 199) + " unselected") + tracker.Observe(input.NewKeyEvent(0, false, "Shift_L")) + tracker.Observe(input.NewKeyEvent(0, true, "Right")) + entry.CaretCharacter = "o" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + expect("o") +} + +func TestLongSelectionWithoutLineNavigationKeepsBoundedSpeech(t *testing.T) { + var selection textSelection + snapshot := atspi.Snapshot{BusName: ":1.7", Path: "/entry"} + selection.reset(snapshot) + snapshot.SelectionEnd, snapshot.SelectionText = 200, strings.Repeat("a", 200) + if got := selection.changed(snapshot); got != "selected" { + t.Fatalf("large non-line selection = %q", got) + } +} + +func TestLongLineSelectionExtendsUpward(t *testing.T) { + var selection textSelection + snapshot := atspi.Snapshot{BusName: ":1.7", Path: "/entry", SelectionStart: 200, SelectionEnd: 201, SelectionText: "z"} + selection.reset(snapshot) + snapshot.SelectionStart, snapshot.SelectionText = 0, strings.Repeat("a", 200)+"z" + if got, want := selection.changedWithLimit(snapshot, lineSelectionFragmentLimit), strings.Repeat("a", 200)+" selected"; got != want { + t.Fatalf("upward line selection = %q, want %q", got, want) + } +} + +func TestClipboardFeedbackRequiresEditableMutation(t *testing.T) { + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event)} + tracker := &input.IntentTracker{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) + }() + defer func() { cancel(); <-done }() + expect := func(want string) { + t.Helper() + select { + case got := <-queue.requests: + if got.Text != want { + t.Fatalf("speech = %q, want %q", got.Text, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } + entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Name: "Message", Editable: true, Focused: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + expect("Message, entry") + tracker.Observe(input.NewKeyEvent(0, true, "Control_L")) + tracker.Observe(input.NewKeyEvent(0, true, "v")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "hello") + expect("pasted from clipboard") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, " again") + entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 5, "hello" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, entry) + entry.CaretCharacter = "o" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + select { + case got := <-queue.requests: + t.Fatalf("paste follow-up interrupted announcement: %q", got.Text) + case <-time.After(30 * time.Millisecond): + } + tracker.Observe(input.NewKeyEvent(0, true, "x")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "hello") + expect("cut") + + password := entry + password.Path, password.Role = "/password", "password text" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, password) + expect("Message, password text") + tracker.Observe(input.NewKeyEvent(0, true, "v")) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, password, "secret") + select { + case got := <-queue.requests: + t.Fatalf("protected edit spoke: %q", got.Text) + case <-time.After(30 * time.Millisecond): + } +} diff --git a/internal/app/version.go b/internal/app/version.go new file mode 100644 index 0000000..a985b07 --- /dev/null +++ b/internal/app/version.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import "git.stormux.org/storm/cthulhu/internal/version" + +func presentVersion(speech speechEnqueuer, atspiVersion, sessionType, sessionDesktop string) { + speech.Interrupt() + speech.Enqueue(speechRequest{ + Text: version.Details(atspiVersion, sessionType, sessionDesktop), + Role: "system", + Cause: "version", + }) +} diff --git a/internal/app/version_test.go b/internal/app/version_test.go new file mode 100644 index 0000000..80a2d12 --- /dev/null +++ b/internal/app/version_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/version" +) + +func TestPresentVersion(t *testing.T) { + speech := &fakeSpeechQueue{ + spoken: make(chan string, 1), + requests: make(chan speechRequest, 1), + interrupt: make(chan struct{}, 1), + } + presentVersion(speech, "2.58.0", "x11", "GNOME") + select { + case <-speech.interrupt: + default: + t.Fatal("version presentation did not interrupt speech") + } + request := <-speech.requests + want := version.Details("2.58.0", "x11", "GNOME") + if request.Text != want || request.Role != "system" || request.Cause != "version" { + t.Fatalf("request = %+v, want %q as system version", request, want) + } +} diff --git a/internal/app/webscripts.go b/internal/app/webscripts.go new file mode 100644 index 0000000..607dd3d --- /dev/null +++ b/internal/app/webscripts.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/webscripts" +) + +// webScriptController is owned by processEventsWithInput. Delayed presentation +// runs in that loop, preserving event order without timer callback goroutines. +type webScriptController struct { + registry webscripts.Registry + timer *time.Timer + due <-chan time.Time + speech speechEnqueuer + notifications *notification.Presenter + braille interface { + Flash(string, config.BrailleOptions) bool + } + live *liveSettings + gate *focusGate +} + +func (controller *webScriptController) handle(event atspi.Event) bool { + retiring := event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent && event.Detail1 != 0 + if retiring { + id := event.Source.ID() + if event.Type == atspi.WindowDestroyedEvent && event.Window.Valid() { + id = event.Window + } + controller.registry.Retire(id.BusName, id.Path) + } + messages, handled := controller.registry.Receive(event.Source.ApplicationProcess, + event.Type, event.Detail1, event.WebNotification, time.Now()) + controller.present(messages) + if handled || retiring { + controller.schedule() + } + return handled +} + +func (controller *webScriptController) schedule() { + deadline := controller.registry.Deadline() + controller.due = nil + if controller.timer != nil { + controller.timer.Stop() + } + if deadline.IsZero() { + return + } + if controller.timer == nil { + controller.timer = time.NewTimer(time.Until(deadline)) + } else { + controller.timer.Reset(time.Until(deadline)) + } + controller.due = controller.timer.C +} + +func (controller *webScriptController) flush() { + if controller.gate.Locked() { + controller.reset() + return + } + controller.present(controller.registry.Flush(time.Now())) + controller.schedule() +} + +func (controller *webScriptController) present(messages []string) { + if controller.gate.Locked() { + return + } + for _, message := range messages { + controller.notifications.Save(message, notification.SaveOptions{AppName: "Steam", Source: "steam"}) + if controller.braille != nil { + options := config.DefaultSettings().Braille + if controller.live != nil { + options = controller.live.Load().Braille + } + controller.braille.Flash(message, options) + } + controller.speech.Interrupt() + controller.speech.Enqueue(speechRequest{ + Text: message, Role: "notification", Cause: "steam-notification", BypassFocusGate: true, + }) + } +} + +func (controller *webScriptController) reset() { + controller.registry.Reset() + controller.schedule() +} diff --git a/internal/app/webscripts_test.go b/internal/app/webscripts_test.go new file mode 100644 index 0000000..6cba8bb --- /dev/null +++ b/internal/app/webscripts_test.go @@ -0,0 +1,114 @@ +// 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" + "git.stormux.org/storm/cthulhu/internal/notification" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/webscripts" +) + +func steamToastEvent(text string) atspi.Event { + source := atspi.NewSnapshot("/fragment", "", "static", "", "") + source.BusName = ":1.42" + source.ApplicationName = "Chromium" + source.ApplicationProcess = "steamwebhelper" + event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, source, text) + event.WebNotification = &webscripts.Notification{BusName: source.BusName, RootPath: "/toast", WindowPath: "/steam", Text: text} + event.ForegroundMismatch = true + return event +} + +func TestSteamBackgroundNotificationReachesSpeechBrailleAndHistory(t *testing.T) { + adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 8)}, changes: make(chan struct{}, 1)} + state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} + adapter.state.Store(&state) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + gate := &focusGate{} + gate.SetSuspended(true) + display := &flashRecorder{} + history := notification.NewPresenter() + output := outputQueue{speech: gatedSpeech{speech: queue, gate: gate}, braille: display} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0, nil, nil, gate, history) + }() + defer func() { cancel(); <-done }() + adapter.events <- steamToastEvent("Username") + adapter.events <- steamToastEvent("Playing: Example Game") + select { + case message := <-queue.spoken: + if message != "Steam: Username Playing: Example Game" { + t.Fatalf("fragmented message: %q", message) + } + case <-time.After(2 * time.Second): + t.Fatal("background Steam notification was filtered or timer did not fire") + } + if !gate.Suspended() { + t.Fatal("background toast took foreground ownership") + } + if entries := history.Entries(); len(entries) != 1 || entries[0].Message != "Steam: Username Playing: Example Game" { + t.Fatalf("notification history: %+v", entries) + } + if len(display.messages) != 1 || display.messages[0] != "Steam: Username Playing: Example Game" { + t.Fatalf("braille messages: %q", display.messages) + } + // A Chromium page with identical metadata cannot borrow the Steam match. + other := steamToastEvent("Unrelated browser content") + other.Source.ApplicationProcess = "chromium" + other.Source.LiveRegion = true + adapter.events <- other + // A normal accepted focus event is a barrier for the rejected event above. + focus := ownerEvent(atspi.FocusedStateEvent, ":1.43", "/window", "/button") + focus.Source.Name, focus.Source.Role = "Current control", "button" + focus.Source.Focused, focus.Detail1, focus.Foreground = true, 1, state + adapter.events <- focus + expectActivationSpeech(t, queue.spoken, "Current control, button") + select { + case message := <-queue.spoken: + t.Fatalf("background Chromium spoke: %q", message) + default: + } + if len(history.Entries()) != 1 { + t.Fatal("background Chromium entered notification history") + } +} + +func TestSteamNotificationTimerCancelledOnLockAndDestruction(t *testing.T) { + for _, retire := range []string{"lock", "root", "window"} { + t.Run(retire, func(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 1)} + history := notification.NewPresenter() + gate := &focusGate{} + controller := &webScriptController{speech: queue, notifications: history, gate: gate} + defer controller.reset() + if !controller.handle(steamToastEvent("Pending content")) || controller.due == nil { + t.Fatal("toast did not arm a timer") + } + switch retire { + case "lock": + gate.secure.Store(true) + controller.flush() + case "root": + event := atspi.NewEvent(atspi.DefunctStateEvent, atspi.Snapshot{BusName: ":1.42", Path: "/toast"}) + event.Detail1 = 1 + controller.handle(event) + case "window": + controller.handle(atspi.NewEvent(atspi.WindowDestroyedEvent, atspi.Snapshot{BusName: ":1.42", Path: "/steam"})) + } + if controller.due != nil || !controller.registry.Deadline().IsZero() || len(history.Entries()) != 0 { + t.Fatal("retired toast remained queued") + } + }) + } +} diff --git a/internal/app/window_owner.go b/internal/app/window_owner.go new file mode 100644 index 0000000..d0846c4 --- /dev/null +++ b/internal/app/window_owner.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// windowOwner keeps application, top-level window, and object focus as +// separate lifecycles. The event loop remains the sole owner of this state. +type windowOwner struct { + applicationBus string + window atspi.ObjectID + focus atspi.ObjectID + activated bool + title string +} + +func (owner *windowOwner) activate(event atspi.Event) (duplicate bool) { + window := eventWindow(event) + if window.Valid() && window == owner.window { + if owner.activated { + return true + } + owner.activated = true + owner.title = activationTitle(event) + return false + } + owner.window = window + owner.applicationBus = window.BusName + owner.focus = event.Source.ID() + owner.activated = true + owner.title = activationTitle(event) + return false +} + +func (owner *windowOwner) follow(event atspi.Event) bool { + window := eventWindow(event) + if owner.window.Valid() && window.Valid() && window != owner.window { + // A focused object can precede window:activate during an actual X11 + // handoff. The native adapter has already rejected objects in a known + // inactive window, so confirmed focused state may establish the new owner. + if !event.Source.Focused { + return false + } + owner.window = window + owner.applicationBus = window.BusName + owner.activated = false + owner.title = "" + } + if !owner.window.Valid() && window.Valid() { + owner.window = window + owner.applicationBus = window.BusName + owner.activated = false + owner.title = "" + } + owner.focus = event.Source.ID() + return true +} + +func (owner *windowOwner) end(event atspi.Event, consumedCommand bool) bool { + window := eventWindow(event) + if !owner.window.Valid() || !window.Valid() || window != owner.window { + return false + } + // Python rechecks active state because passive X11 grabs can emit a + // transient deactivate/activate pair while the same window stays active. + if event.Type == atspi.WindowDeactivatedEvent { + if consumedCommand || event.Source.StatesKnown && event.Source.Active { + return false + } + } + owner.clear() + return true +} + +// containsLoad permits a document replacement only inside the existing focus +// context. A load completion is not a window activation, even when a provider +// still reports focused or active state for a background document. +func (owner *windowOwner) containsLoad(event atspi.Event) bool { + window := eventWindow(event) + if owner.window.Valid() { + // The old document may become defunct before its replacement loads. + // Its window still owns the context even though object focus is gone. + return window == owner.window && event.Source.BusName == owner.applicationBus + } + return owner.focus.Valid() && event.Source.BusName == owner.focus.BusName +} + +func (owner *windowOwner) defunct(event atspi.Event) (windowEnded, focusEnded bool) { + if event.Detail1 == 0 { + return false, false + } + id := event.Source.ID() + if owner.window.Valid() && id == owner.window { + owner.clear() + return true, true + } + if owner.focus.Valid() && id == owner.focus { + owner.focus = atspi.ObjectID{} + return false, true + } + return false, false +} + +func (owner *windowOwner) clear() { + owner.applicationBus = "" + owner.window = atspi.ObjectID{} + owner.focus = atspi.ObjectID{} + owner.activated = false + owner.title = "" +} + +func activationTitle(event atspi.Event) string { + if title := strings.TrimSpace(event.WindowTitle); title != "" { + return title + } + if event.Source.ID() == eventWindow(event) { + return strings.TrimSpace(event.Source.Name) + } + return "" +} + +// Track empty names as well so restoring an earlier title is a real change. +func (owner *windowOwner) changeTitle(event atspi.Event) bool { + if !owner.window.Valid() || event.Source.ID() != owner.window { + return false + } + title := strings.TrimSpace(event.Source.Name) + if title == owner.title { + return false + } + owner.title = title + return title != "" +} + +func eventWindow(event atspi.Event) atspi.ObjectID { + if event.Window.Valid() { + return event.Window + } + // Window lifecycle fixtures and older adapters identify the top-level in + // OriginPath. For object events OriginPath is the object itself, so it must + // not be promoted to a window owner. + switch event.Type { + case atspi.WindowActivatedEvent, atspi.WindowDeactivatedEvent, atspi.WindowDestroyedEvent: + return atspi.NewObjectID(event.Source.BusName, event.OriginPath) + default: + return atspi.ObjectID{} + } +} diff --git a/internal/app/window_owner_test.go b/internal/app/window_owner_test.go new file mode 100644 index 0000000..32d27b6 --- /dev/null +++ b/internal/app/window_owner_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func ownerEvent(eventType, bus, window, focus string) atspi.Event { + event := atspi.NewEvent(eventType, atspi.Snapshot{BusName: bus, Path: focus}) + event.Window = atspi.NewObjectID(bus, window) + return event +} + +func TestWindowOwnerKeepsWindowAndFocusIndependent(t *testing.T) { + var owner windowOwner + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.40", "/window", "/button") + if owner.activate(activation) { + t.Fatal("first activation was treated as duplicate") + } + if owner.window != activation.Window || owner.focus != activation.Source.ID() || owner.applicationBus != ":1.40" { + t.Fatalf("owner = %#v", owner) + } + + focus := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/window", "/entry") + if !owner.follow(focus) || owner.window != activation.Window || owner.focus != focus.Source.ID() { + t.Fatalf("focus did not remain in active window: %#v", owner) + } + if !owner.activate(activation) || owner.focus != focus.Source.ID() { + t.Fatal("duplicate activation replaced descendant focus") + } +} + +func TestFocusBeforeActivationStillAdmitsFirstWindowActivation(t *testing.T) { + var owner windowOwner + focus := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/window", "/entry") + focus.Source.Focused = true + if !owner.follow(focus) || owner.activated { + t.Fatalf("focus-first ownership = %#v", owner) + } + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.40", "/window", "/window") + if owner.activate(activation) || owner.focus != focus.Source.ID() { + t.Fatalf("first activation lost focus or was skipped: %#v", owner) + } + if !owner.activate(activation) { + t.Fatal("repeated activation was not suppressed") + } +} + +func TestWindowOwnerRejectsBackgroundFocusAndUnrelatedLifecycle(t *testing.T) { + var owner windowOwner + owner.activate(ownerEvent(atspi.WindowActivatedEvent, ":1.40", "/window", "/button")) + if owner.follow(ownerEvent(atspi.FocusedStateEvent, ":1.41", "/other", "/entry")) { + t.Fatal("background focus changed ownership") + } + if owner.end(ownerEvent(atspi.WindowDestroyedEvent, ":1.41", "/other", "/other"), false) { + t.Fatal("unrelated destruction cleared ownership") + } + if !owner.window.Valid() { + t.Fatal("unrelated lifecycle cleared active window") + } +} + +func TestWindowOwnerHandlesTransientDeactivationAndDestruction(t *testing.T) { + var owner windowOwner + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.40", "/window", "/button") + owner.activate(activation) + deactivation := ownerEvent(atspi.WindowDeactivatedEvent, ":1.40", "/window", "/window") + deactivation.Source.StatesKnown = true + deactivation.Source.Active = true + if owner.end(deactivation, false) || !owner.window.Valid() { + t.Fatal("active key-grab deactivation cleared ownership") + } + deactivation.Source.StatesKnown = false + deactivation.Source.Active = false + if owner.end(deactivation, true) || !owner.window.Valid() { + t.Fatal("consumed-command deactivation cleared ownership") + } + destruction := ownerEvent(atspi.WindowDestroyedEvent, ":1.40", "/window", "/window") + if !owner.end(destruction, true) || owner.window.Valid() || owner.focus.Valid() || owner.applicationBus != "" { + t.Fatalf("destruction did not clear ownership: %#v", owner) + } +} + +func TestWindowOwnerClearsDefunctFocusWithoutDroppingWindow(t *testing.T) { + var owner windowOwner + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.40", "/window", "/button") + owner.activate(activation) + defunct := atspi.NewEvent(atspi.DefunctStateEvent, activation.Source) + defunct.Detail1 = 1 + windowEnded, focusEnded := owner.defunct(defunct) + if windowEnded || !focusEnded || !owner.window.Valid() || owner.focus.Valid() { + t.Fatalf("defunct focus result = windowEnded:%v focusEnded:%v owner:%#v", windowEnded, focusEnded, owner) + } + + windowDefunct := atspi.NewEvent(atspi.DefunctStateEvent, atspi.Snapshot{BusName: ":1.40", Path: "/window"}) + windowDefunct.Detail1 = 1 + windowEnded, focusEnded = owner.defunct(windowDefunct) + if !windowEnded || !focusEnded || owner.window.Valid() || owner.applicationBus != "" { + t.Fatalf("defunct window result = windowEnded:%v focusEnded:%v owner:%#v", windowEnded, focusEnded, owner) + } +} diff --git a/internal/app/window_speech_test.go b/internal/app/window_speech_test.go new file mode 100644 index 0000000..13e7cf5 --- /dev/null +++ b/internal/app/window_speech_test.go @@ -0,0 +1,100 @@ +// 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" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +func TestWindowTitleQueuesFollowingPresentation(t *testing.T) { + for _, title := range []string{"Cthulhu Preferences", "Cthulhu Preferences for Example Application"} { + t.Run(title, func(t *testing.T) { + speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + presenter := eventPresenter{state: &eventLoopState{}, logger: slog.New(slog.DiscardHandler), speech: worker} + presenter.state.presentationOptions = presentation.DefaultOptions() + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") + activation.Source.Name, activation.Source.Role = title, "dialog" + activation.WindowTitle = title + presenter.speak(activation) + close(expectSpeechCall(t, speaker, "cancel").done) + titleCall := expectSpeechCall(t, speaker, title) + worker.ProtectCommand(100) + if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 100}) { + t.Fatal("delayed initiating shortcut interrupted the title") + } + + focus := ownerEvent(atspi.FocusedStateEvent, ":1.70", "/window", "/general") + focus.Source.Name, focus.Source.Role = "General", "page tab" + focus.Detail1 = 1 + presenter.speak(focus) + close(titleCall.done) + close(expectSpeechCall(t, speaker, "dialog").done) + // A CANCEL here would discard the title still queued in the + // synthesizer, even though its SPEAK call has returned. + close(expectSpeechCall(t, speaker, "General").done) + close(expectSpeechCall(t, speaker, "page tab").done) + + focus.Source.Path, focus.Source.Name = "/speech", "Speech" + presenter.speak(focus) + close(expectSpeechCall(t, speaker, "cancel").done) + close(expectSpeechCall(t, speaker, "Speech").done) + close(expectSpeechCall(t, speaker, "page tab").done) + }) + } +} + +func TestWindowTitlePairingIsScoped(t *testing.T) { + for _, test := range []struct { + name string + window atspi.ObjectID + untitled bool + interrupt func(*speechWorker) + }{ + {name: "another window", window: atspi.NewObjectID(":1.70", "/other")}, + {name: "another application", window: atspi.NewObjectID(":1.71", "/window")}, + {name: "unowned output"}, + {name: "no title announcement", window: atspi.NewObjectID(":1.70", "/window"), untitled: true}, + {name: "explicit interruption", window: atspi.NewObjectID(":1.70", "/window"), interrupt: (*speechWorker).Interrupt}, + {name: "navigation key", window: atspi.NewObjectID(":1.70", "/window"), interrupt: func(worker *speechWorker) { + worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) + }}, + } { + t.Run(test.name, func(t *testing.T) { + speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} + worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { worker.Stop(context.Background()) }) + worker.Enqueue(speechRequest{Text: "Window title", Window: atspi.NewObjectID(":1.70", "/window"), IntroducesWindow: !test.untitled}) + close(expectSpeechCall(t, speaker, "cancel").done) + titleCall := expectSpeechCall(t, speaker, "Window title") + if test.interrupt != nil { + test.interrupt(worker) + close(expectSpeechCall(t, speaker, "cancel").done) + } + worker.Enqueue(speechRequest{Text: "Next control", Window: test.window}) + close(titleCall.done) + close(expectSpeechCall(t, speaker, "cancel").done) + if test.interrupt != nil { + // The cancellation barrier settles the in-flight title + // before the new focus request performs its own CANCEL. + close(expectSpeechCall(t, speaker, "cancel").done) + } + close(expectSpeechCall(t, speaker, "Next control").done) + }) + } +} diff --git a/internal/app/window_titles.go b/internal/app/window_titles.go new file mode 100644 index 0000000..0ee3bd8 --- /dev/null +++ b/internal/app/window_titles.go @@ -0,0 +1,249 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +type windowTitleSource interface { + ForegroundTitle() atspi.ForegroundTitle + TitleChanges() <-chan struct{} +} + +type pendingWindowTitle struct { + text string + window atspi.ObjectID + epoch uint64 + native xorg.WindowTitle + changed bool + application string + observedAt time.Time +} + +// windowTitleController is owned by the accessibility event loop. Discovery +// stays in the native adapter; this controller owns coalescing, preferences, +// and duplicate suppression across AT-SPI and X11. +type windowTitleController struct { + state *eventLoopState + live *liveSettings + gate *focusGate + speech speechEnqueuer + logger *slog.Logger + source windowTitleSource + foreground foregroundMonitor + lastNative atspi.ForegroundTitle + lastTitle string + application string + pending *pendingWindowTitle + timer *time.Timer + due <-chan time.Time +} + +const ( + windowTitleChangeDelay = 25 * time.Millisecond + windowTitleIntroductionDelay = 250 * time.Millisecond +) + +func (titles *windowTitleController) clearPending() { + if titles.timer != nil { + titles.timer.Stop() + } + titles.timer, titles.due, titles.pending = nil, nil, nil +} + +func (titles *windowTitleController) reset() { + titles.clearPending() + titles.lastTitle, titles.application = "", "" + titles.lastNative = atspi.ForegroundTitle{} +} + +func (titles *windowTitleController) activated(event atspi.Event) { + titles.lastTitle = titles.state.owner.title + // XTerm activation is consumed by the Fenrir handoff before presentation. + // Its native title still needs an introduction, even if AT-SPI named it. + if isXTermSnapshot(event.Source) { + titles.lastTitle = "" + } + titles.application = titles.applicationForEvent(event) + if titles.pending != nil && titles.pending.native.Window == 0 { + titles.clearPending() + } +} + +func (titles *windowTitleController) applicationForEvent(event atspi.Event) string { + if titles.source != nil { + native := titles.source.ForegroundTitle() + if native.Epoch == event.Foreground.Epoch && native.Title.Window != 0 && + native.Title.TitleWindow != native.Title.Window && native.Title.Application != "" { + return native.Title.Application + } + } + if event.Source.ApplicationName != "" { + return event.Source.ApplicationName + } + return event.Source.ApplicationProcess +} + +func (titles *windowTitleController) queue(pending pendingWindowTitle) { + if pending.observedAt.IsZero() { + pending.observedAt = time.Now() + } + if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch && + previous.window == pending.window && previous.native.Window == pending.native.Window && + previous.native.TitleWindow == pending.native.TitleWindow { + pending.changed = false + } + titles.pending = &pending + // Keep the first deadline: continuous progress must not postpone speech + // forever. Each burst speaks its latest value rather than every update. + if titles.timer == nil { + delay := windowTitleIntroductionDelay + if pending.changed { + delay = windowTitleChangeDelay + } + titles.timer = time.NewTimer(delay) + titles.due = titles.timer.C + } +} + +func isWindowTitleEvent(event atspi.Event) bool { + return event.Type == atspi.AccessibleNameEvent && + (event.Source.Role == "frame" || event.Source.Role == "window" || event.Source.Role == "dialog") +} + +func (titles *windowTitleController) accessible(event atspi.Event) bool { + if !isWindowTitleEvent(event) { + return false + } + if event.Source.ID() == titles.state.owner.window && strings.TrimSpace(event.Source.Name) == "" { + titles.state.owner.changeTitle(event) + titles.clearPending() + titles.lastTitle = "" + return true + } + if !titles.state.owner.changeTitle(event) { + return true + } + titles.queue(pendingWindowTitle{text: strings.TrimSpace(event.Source.Name), + window: titles.state.owner.window, epoch: titles.state.foregroundState.Epoch, + changed: true, application: titles.application}) + return true +} + +func (titles *windowTitleController) nativeChanged() { + latest := titles.source.ForegroundTitle() + if latest == titles.lastNative { + return + } + previous := titles.lastNative + titles.lastNative = latest + if latest.Title.Window == 0 { + titles.reset() + return + } + switched := latest.Epoch != previous.Epoch || latest.Title.Window != previous.Title.Window || + latest.Title.TitleWindow != previous.Title.TitleWindow + if switched { + titles.clearPending() + // An AT-SPI activation may already have introduced this client. + titles.lastTitle = titles.state.owner.title + if strings.EqualFold(latest.Title.Application, "xterm") { + titles.lastTitle = "" + } + } + if latest.Title.Title == "" { + titles.clearPending() + titles.lastTitle = "" + return + } + changed := !switched + // AT-SPI can introduce a window before its first X11 title sample. A + // later differing sample is still a title change, not a new activation. + if switched && titles.state.owner.activated && titles.state.owner.title != "" && + latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") { + changed = true + } + titles.queue(pendingWindowTitle{text: latest.Title.Title, epoch: latest.Epoch, + native: latest.Title, changed: changed}) +} + +func equivalentWindowTitles(first, second string) bool { + normalize := func(value string) string { return strings.Join(strings.Fields(strings.ToLower(value)), " ") } + first, second = normalize(first), normalize(second) + if first == "" || second == "" { + return false + } + // Compare full titles: a changed status suffix is meaningful information. + return first == second +} + +func (titles *windowTitleController) enabled() bool { + return titles.live == nil || titles.live.Load().Reading.AutomaticallyReadTitleChanges +} + +func (titles *windowTitleController) flush() { + pending := titles.pending + titles.clearPending() + if pending == nil || titles.gate.Locked() { + return + } + // Only a native foreground introduction may cross the terminal handoff. + // Content and subsequent title changes remain Fenrir's responsibility. + xtermIntroduction := !pending.changed && pending.native.Window != 0 && + strings.EqualFold(pending.native.Application, "xterm") + if titles.gate.Suspended() && !xtermIntroduction { + return + } + if titles.foreground != nil && pending.epoch != titles.foreground.Foreground().Epoch { + return + } + if pending.native.Window != 0 { + latest := titles.source.ForegroundTitle() + if latest.Epoch != pending.epoch || latest.Title != pending.native { + return + } + pending.window = titles.state.owner.window + pending.application = titles.application + if !pending.window.Valid() || pending.native.TitleWindow != pending.native.Window { + pending.application = pending.native.Application + } + } else if pending.window != titles.state.owner.window { + return + } + if strings.HasPrefix(strings.ToLower(pending.application), "cthulhu") { + pending.application = "" + } + if pending.application != "" && titles.live != nil && titles.live.onApplication != nil { + titles.live.onApplication(pending.application) + // Application switching is asynchronous. Wait for its committed + // settings before reading a title, including Wine without AT-SPI. + applied := titles.live.application.Load() + if applied == nil || *applied != pending.application { + titles.queue(*pending) + return + } + } + if pending.changed && !titles.enabled() { + titles.lastTitle = pending.text + return + } + if equivalentWindowTitles(titles.lastTitle, pending.text) { + return + } + titles.lastTitle = pending.text + if titles.logger != nil { + titles.logger.Debug("queued automatic window title", "title_changed", pending.changed, + "observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds()) + } + titles.speech.Enqueue(speechRequest{Text: pending.text, Role: "system", Cause: "window-title", + Window: pending.window, Append: true, BypassFocusGate: xtermIntroduction, + ExcludeFromHistory: titles.state.historyWindow.Valid() && pending.window == titles.state.historyWindow}) +} diff --git a/internal/app/window_titles_native_test.go b/internal/app/window_titles_native_test.go new file mode 100644 index 0000000..176c490 --- /dev/null +++ b/internal/app/window_titles_native_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + "os" + "os/exec" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestNativeAutomaticTitleDispatchLatency(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_WINDOW_TITLES") != "1" { + t.Skip("private Xvfb and D-Bus fixture only") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "python3", "-u", "../../tests/window_title_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("fixture did not start") + } + adapter := atspi.New() + if err = adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + completed := make(chan error, 1) + go func() { completed <- processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }() + defer func() { + cancel() + if err := <-completed; err != nil { + t.Error(err) + } + }() + wait := func(want string, deadline <-chan time.Time) { + t.Helper() + for { + select { + case got := <-queue.spoken: + if got == want { + return + } + case <-deadline: + t.Fatalf("title %q was not queued promptly", want) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + wait("Example title", time.After(2*time.Second)) + for _, title := range []string{"Status one", "Status two", "Status three"} { + data, _ := json.Marshal(map[string]string{"action": "title", "window": "normal", "title": title}) + began := time.Now() + deadline := time.After(200 * time.Millisecond) + if _, err = stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("fixture command failed") + } + wait(title, deadline) + t.Logf("X11 title update to speech queue: %s", time.Since(began)) + } +} diff --git a/internal/app/window_titles_test.go b/internal/app/window_titles_test.go new file mode 100644 index 0000000..b631e4d --- /dev/null +++ b/internal/app/window_titles_test.go @@ -0,0 +1,397 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "path/filepath" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +type titleProbeSource struct{ title atspi.ForegroundTitle } + +func (source *titleProbeSource) ForegroundTitle() atspi.ForegroundTitle { return source.title } +func (source *titleProbeSource) TitleChanges() <-chan struct{} { return nil } +func (source *titleProbeSource) Foreground() atspi.ForegroundState { + return atspi.ForegroundState{Epoch: source.title.Epoch, Client: xorg.Foreground{Known: true, Window: source.title.Title.Window}} +} +func (source *titleProbeSource) ForegroundChanges() <-chan struct{} { return nil } + +type titleProbeQueue struct { + requests []speechRequest + interruptions int +} + +func (queue *titleProbeQueue) Enqueue(request speechRequest) { + queue.requests = append(queue.requests, request) +} +func (queue *titleProbeQueue) Interrupt() { queue.interruptions++ } + +func titleControllerFixture(t *testing.T) (*windowTitleController, *titleProbeQueue, atspi.Event) { + t.Helper() + queue := &titleProbeQueue{} + live := &liveSettings{} + live.Store(config.DefaultSettings()) + titles := &windowTitleController{state: &eventLoopState{}, live: live, speech: queue} + activation := ownerEvent(atspi.WindowActivatedEvent, ":1.50", "/window", "/window") + activation.Source.Name, activation.Source.Role = "Original", "frame" + titles.state.owner.activate(activation) + titles.activated(activation) + t.Cleanup(titles.reset) + changed := activation + changed.Type = atspi.AccessibleNameEvent + return titles, queue, changed +} + +func TestAutomaticTitlePolicyCoalescesAndPreservesSpeech(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + for _, text := range []string{"Loading", "Loading 50%", "Loading 100%"} { + changed.Source.Name = text + if !titles.accessible(changed) { + t.Fatal("title event not consumed") + } + } + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != "Loading 100%" || + !queue.requests[0].Append || queue.interruptions != 0 { + t.Fatalf("title burst = %+v, interruptions %d", queue.requests, queue.interruptions) + } + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 1 { + t.Fatal("duplicate title was announced") + } + changed.Source.Name = "" + titles.accessible(changed) + changed.Source.Name = "Loading 100%" + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 2 { + t.Fatal("restored title was not announced") + } +} + +func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + display := &flashRecorder{} + titles.speech = outputQueue{speech: queue, braille: display, live: titles.live} + focused := titles.state.owner.focus + changed.Source.Name = "Updated title" + titles.accessible(changed) + titles.flush() + if len(display.messages) != 1 || display.messages[0] != "Updated title" || titles.state.owner.focus != focused { + t.Fatalf("title braille = %q, focus = %+v", display.messages, titles.state.owner.focus) + } + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + changed.Source.Name = "Silent title" + titles.accessible(changed) + titles.flush() + if len(display.messages) != 1 { + t.Fatal("disabled title still flashed on braille") + } +} + +func TestAutomaticTitlesRejectBackgroundDisabledAndStaleEvents(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + background := changed + background.Source.Path = "/background" + background.Source.Name = "Background" + titles.accessible(background) + titles.flush() + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + changed.Source.Name = "Disabled" + titles.accessible(changed) + titles.flush() + settings.Reading.AutomaticallyReadTitleChanges = true + titles.live.Store(settings) + changed.Source.Name = "Stale" + titles.accessible(changed) + titles.state.owner.clear() + titles.flush() + if len(queue.requests) != 0 { + t.Fatalf("unwanted titles = %+v", queue.requests) + } +} + +func TestAutomaticTitlesCancelEmptyAndLockedUpdates(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + changed.Source.Name = "Transient" + titles.accessible(changed) + changed.Source.Name = "" + titles.accessible(changed) + titles.flush() + titles.gate = newFocusGate(nil) + changed.Source.Name = "Locked title" + titles.accessible(changed) + titles.gate.secure.Store(true) + titles.flush() + titles.gate.secure.Store(false) + titles.gate.suspended.Store(true) + changed.Source.Name = "XTerm handoff" + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 0 { + t.Fatalf("unwanted titles = %+v", queue.requests) + } +} + +func TestNativeXTermIntroductionDuringHandoff(t *testing.T) { + for _, accessible := range []bool{false, true} { + t.Run(map[bool]string{false: "native only", true: "accessible activation"}[accessible], func(t *testing.T) { + titles, queue, activation := titleControllerFixture(t) + titles.state.owner.clear() + titles.gate = newFocusGate(nil) + titles.gate.SetSuspended(true) + titles.speech = gatedSpeech{speech: queue, gate: titles.gate} + source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Shell", Application: "xterm"}}} + titles.source, titles.foreground = source, source + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + if accessible { + activation.Source.Name, activation.Source.ApplicationName = "Shell", "xterm" + activation.Foreground.Epoch = 1 + titles.state.owner.activate(activation) + titles.activated(activation) + } + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != "Shell" || !titles.gate.Suspended() { + t.Fatalf("handoff title = %+v; suspended = %t", queue.requests, titles.gate.Suspended()) + } + titles.nativeChanged() + titles.flush() + source.title.Title.Title = "Shell updated" + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 1 { + t.Fatal("handoff announced duplicate or terminal title change") + } + // Separate xterms can have the same title; each switch introduces it. + source.title.Epoch++ + source.title.Title.Window, source.title.Title.TitleWindow = 21, 21 + source.title.Title.Title = "Shell" + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 2 || queue.requests[1].Text != "Shell" { + t.Fatalf("second terminal introduction = %+v", queue.requests) + } + source.title.Epoch++ + source.title.Title.Window, source.title.Title.TitleWindow = 22, 22 + source.title.Title.Title = "Second shell" + titles.nativeChanged() + titles.gate.secure.Store(true) + titles.flush() + if len(queue.requests) != 2 { + t.Fatal("locked screen announced terminal title") + } + }) + } +} + +func TestNativeTitlesSharePolicyAndActivationDeduplication(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + source := &titleProbeSource{title: atspi.ForegroundTitle{Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Original"}}} + titles.source, titles.foreground = source, source + titles.nativeChanged() + titles.flush() + changed.Source.Name = "Changed" + titles.accessible(changed) + source.title.Title.Title = "Changed" + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != "Changed" { + t.Fatalf("duplicate source output = %+v", queue.requests) + } + source.title.Title.Title = "Changed again" + titles.nativeChanged() + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + titles.flush() + if len(queue.requests) != 1 { + t.Fatal("native change ignored disabled preference") + } + titles.state.owner.clear() + source.title.Epoch++ + source.title.Title.Window, source.title.Title.TitleWindow, source.title.Title.Title = 20, 20, "Other window" + titles.nativeChanged() + source.title.Title.Title = "Other window ready" + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 2 || queue.requests[1].Text != "Other window ready" { + t.Fatal("disabled title changes suppressed initial window introduction") + } +} + +func TestNativeTitleDropsOldForegroundAndTracksWineChildSwitch(t *testing.T) { + titles, queue, _ := titleControllerFixture(t) + titles.state.owner.clear() + source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 10, TitleWindow: 11, Title: "Wine game"}}} + titles.source, titles.foreground = source, source + titles.nativeChanged() + source.title.Epoch++ + titles.flush() + if len(queue.requests) != 0 { + t.Fatal("old foreground title survived") + } + titles.nativeChanged() + titles.flush() + source.title.Title.TitleWindow, source.title.Title.Title = 12, "Wine dialog" + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 2 || queue.requests[1].Text != "Wine dialog" { + t.Fatalf("Wine child switch = %+v", queue.requests) + } +} + +func TestAutomaticTitlesWaitForApplicationOverride(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + titles.application = "Example Application" + titles.live.onApplication = func(string) {} + changed.Source.Name = "Changing title" + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 0 || titles.pending == nil { + t.Fatal("title raced application settings") + } + application := titles.application + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + titles.live.application.Store(&application) + titles.flush() + if len(queue.requests) != 0 || titles.pending != nil { + t.Fatal("application override was not honored") + } +} + +func TestLateNativeSampleHonorsDisabledTitleChanges(t *testing.T) { + titles, queue, _ := titleControllerFixture(t) + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + source := &titleProbeSource{title: atspi.ForegroundTitle{Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Changed after activation"}}} + titles.source, titles.foreground = source, source + titles.nativeChanged() + titles.flush() + if len(queue.requests) != 0 { + t.Fatal("first native sample bypassed disabled preference") + } +} + +func TestTitlePreferenceAppliesPersistsAndRestoresAcrossApplications(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + store, err := config.NewStore(settings.Path) + if err != nil { + t.Fatal(err) + } + if err = store.Save(settings); err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.live = &liveSettings{} + worker.live.Store(settings) + ctx := context.Background() + worker.switchApplication(ctx, "Noisy Application") + worker.beginEdit(ctx, "\x00") + values := preferences.FromSettings(worker.editing) + if !values.Reading.AutomaticallyReadTitleChanges { + t.Fatal("checkbox defaults off") + } + values.Reading.AutomaticallyReadTitleChanges = false + if response := worker.apply(ctx, preferences.Request{Values: values, Save: true}); !response.Accepted { + t.Fatal(response.Message) + } + worker.editingActive = false + worker.switchApplication(ctx, "Other Application") + if !worker.live.Load().Reading.AutomaticallyReadTitleChanges { + t.Fatal("override leaked") + } + worker.switchApplication(ctx, "Noisy Application") + if worker.live.Load().Reading.AutomaticallyReadTitleChanges { + t.Fatal("override lost on return") + } + reopened, err := config.NewStore(settings.Path) + if err != nil { + t.Fatal(err) + } + loaded, found, err := reopened.LoadForApplication("Noisy Application") + if err != nil || !found || loaded.Reading.AutomaticallyReadTitleChanges { + t.Fatalf("saved override = %t, %t, %v", loaded.Reading.AutomaticallyReadTitleChanges, found, err) + } + global, err := reopened.Load() + if err != nil || !global.Reading.AutomaticallyReadTitleChanges { + t.Fatalf("global default changed: %v", err) + } +} + +func TestWineTitlesUseSharedControllerWhileControlsStayReadable(t *testing.T) { + titles, queue, _ := titleControllerFixture(t) + titles.state.owner.clear() + titles.lastTitle = "" + control := make(chan string, 2) + owner := newWineEventPresenter(func(text string) bool { control <- text; return true }) + defer owner.Close() + connectWineWindowTitles(owner, titles.live, &fakeAdapter{}) + owner.pending = &winePresentation{source: "control", window: "Wine game", message: "Play, button", parts: []string{"Play", "button"}} + owner.flush() + if got := <-control; got != "Play, button" { + t.Fatalf("control included title: %q", got) + } + titles.wineChanged(<-titles.live.wineTitles) + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != "Wine game" { + t.Fatal("Wine initial title missing") + } + settings := titles.live.Load() + settings.Reading.AutomaticallyReadTitleChanges = false + titles.live.Store(settings) + owner.pending = &winePresentation{source: "control", window: "Wine game loading", message: "Play, button", parts: []string{"Play", "button"}} + owner.flush() + <-control + titles.wineChanged(<-titles.live.wineTitles) + titles.flush() + if len(queue.requests) != 1 { + t.Fatal("Wine helper bypassed preference") + } + owner.pending = &winePresentation{source: "window", window: "Wine game ready", message: "Wine game ready", parts: []string{"Wine game ready"}} + owner.flush() + titles.wineChanged(<-titles.live.wineTitles) + titles.flush() + select { + case text := <-control: + t.Fatalf("title-only helper focus bypassed preference: %q", text) + default: + } + if len(queue.requests) != 1 { + t.Fatal("title-only focus bypassed preference") + } +} + +func TestWineTitleReportsCannotClaimUnrelatedForeground(t *testing.T) { + titles, queue, _ := titleControllerFixture(t) + source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Terminal"}}} + titles.source, titles.foreground = source, source + titles.wineChanged(wineWindowTitle{text: "Background game", foreground: source.title}) + titles.flush() + if len(queue.requests) != 0 { + t.Fatal("background helper title was announced") + } +} diff --git a/internal/app/wine_events.go b/internal/app/wine_events.go new file mode 100644 index 0000000..f2bf370 --- /dev/null +++ b/internal/app/wine_events.go @@ -0,0 +1,217 @@ +package app + +import ( + "fmt" + "log/slog" + "slices" + "strings" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/remote" +) + +// wineEventPresenter coalesces transient Wine focus bursts before presenting +// normalized helper events. The helper never passes native object references. +type wineEventPresenter struct { + // mu serializes D-Bus actions with timer output and shutdown. Native + // objects remain in Wine; focused/protected live until the next focus event. + mu sync.Mutex + present func(string) bool + windowTitle func(string) + logger *slog.Logger + timer *time.Timer + timerGeneration uint64 + pending *winePresentation + focused string + protected bool + window string + lastKey string + lastTime time.Time + closed bool +} + +type winePresentation struct { + source, window, message string + parts []string +} + +func newWineEventPresenter(present func(string) bool) *wineEventPresenter { + return &wineEventPresenter{present: present} +} + +func (owner *wineEventPresenter) Present(event remote.AccessibleEvent) bool { + return owner.present_event(event, nil) +} + +func (owner *wineEventPresenter) present_event(event remote.AccessibleEvent, editor func(remote.AccessibleEvent) bool) bool { + owner.mu.Lock() + defer owner.mu.Unlock() + kind := strings.ToLower(event.EventType) + if owner.closed { + return false + } + if kind == "focus" { + owner.focused = event.SourceID + owner.protected = event.Description == "password" || slices.Contains(strings.Split(event.States, ", "), "password") + } else if kind != "value" && kind != "selection" && kind != "text" && kind != "name" && kind != "state" { + return false + } else if event.SourceID != owner.focused { + window := owner.window + if owner.pending != nil { + window = owner.pending.window + } + if kind != "selection" || event.WindowTitle == "" || event.WindowTitle != window { + return false + } + } + if kind == "state" { + owner.protected = slices.Contains(strings.Split(event.States, ", "), "password") || event.Description == "password" + } + if owner.protected && event.SourceID == owner.focused { + event.Value = "" + } + if kind == "text" { + if owner.protected || event.Name == "" { + return false + } + // Establish the queued focus before the action. Neither focus merging + // nor duplicate suppression may consume repeated characters/actions. + owner.flush_pending() + if owner.logger != nil { + owner.logger.Debug("Wine editor feedback", "source", event.SourceID, + "native_utf16_selection", event.States, "text", event.Name) + } + // Retain action metadata so character output uses the reader's + // uppercase and custom typing voices after focus/password filtering. + if editor != nil { + return editor(event) + } + return owner.present(event.Name) + } + role := event.Role + if strings.EqualFold(role, "client") { + role = "" + } + parts := make([]string, 0, 5) + for _, part := range []string{event.Name, role, event.Value, event.Description} { + if part != "" { + parts = append(parts, part) + } + } + // Only the helper's normalized MSAA states are presentation data. Editor + // offsets/action kinds in this same field stay in debug diagnostics. + for _, state := range strings.Split(event.States, ", ") { + switch state { + case "checked", "not checked", "half checked", "selected", "pressed", "expanded", "collapsed", "unavailable", "read only", "password", "busy": + if !slices.Contains(parts, state) { + parts = append(parts, state) + } + } + } + if event.Position > 0 && event.Count > 0 { + parts = append(parts, fmt.Sprintf("%d of %d", event.Position, event.Count)) + } + if len(parts) == 0 && (kind != "focus" || event.WindowTitle == "") { + return false + } + message := strings.Join(parts, ", ") + key := event.SourceID + "\x00" + message + if message == "" { + key = event.SourceID + "\x00" + event.WindowTitle + } + // Losing native focus emits a state event for the previous control before + // the new focus arrives. Focus bookkeeping is absent from the normalized + // states, so an unchanged semantic presentation must stay quiet even when + // the last announcement was more than 100 ms ago. + if kind == "state" && key == owner.lastKey { + if owner.logger != nil { + owner.logger.Debug("Wine state unchanged", "source", event.SourceID, "text", message) + } + return false + } + if key == owner.lastKey && time.Since(owner.lastTime) < 100*time.Millisecond { + if kind == "focus" { + owner.pending = nil + } + return false + } + if kind == "focus" { + owner.pending = &winePresentation{source: event.SourceID, window: event.WindowTitle, message: message, parts: parts} + if owner.timer == nil { + generation := owner.timerGeneration + owner.timer = time.AfterFunc(50*time.Millisecond, func() { owner.flush_generation(generation) }) + } + return true + } + if pending := owner.pending; pending != nil && + (pending.source == event.SourceID || event.WindowTitle != "" && pending.window == event.WindowTitle) { + pending.source = event.SourceID + pending.message = message + pending.parts = parts + return true + } + owner.lastKey, owner.lastTime = key, time.Now() + return owner.present(message) +} + +func (owner *wineEventPresenter) flush() { + owner.mu.Lock() + defer owner.mu.Unlock() + owner.flush_pending() +} + +func (owner *wineEventPresenter) flush_generation(generation uint64) { + owner.mu.Lock() + defer owner.mu.Unlock() + if generation == owner.timerGeneration { + owner.flush_pending() + } +} + +// flush_pending requires mu to be held, including for immediate editor +// actions. Stop the old timer so it cannot flush a later focus burst early. +func (owner *wineEventPresenter) flush_pending() { + owner.timerGeneration++ + if owner.timer != nil { + owner.timer.Stop() + } + owner.timer = nil + pending := owner.pending + owner.pending = nil + if owner.closed || pending == nil { + return + } + message := pending.message + if owner.windowTitle != nil && message == pending.window { + // A client-name-only focus is a title report, not a control label. + message = "" + } + if pending.window != "" { + if pending.window != owner.window && owner.windowTitle != nil { + owner.windowTitle(pending.window) + } else if owner.windowTitle == nil && pending.window != owner.window && !slices.Contains(pending.parts, pending.window) { + if message != "" { + message += ", " + } + message += pending.window + } + owner.window = pending.window + } + owner.lastKey = pending.source + "\x00" + pending.message + owner.lastTime = time.Now() + if message != "" { + owner.present(message) + } +} + +func (owner *wineEventPresenter) Close() { + owner.mu.Lock() + defer owner.mu.Unlock() + owner.closed = true + owner.pending = nil + if owner.timer != nil { + owner.timer.Stop() + owner.timer = nil + } +} diff --git a/internal/app/wine_events_native_test.go b/internal/app/wine_events_native_test.go new file mode 100644 index 0000000..4ec557a --- /dev/null +++ b/internal/app/wine_events_native_test.go @@ -0,0 +1,114 @@ +// 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" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/remote" + "github.com/godbus/dbus/v5" +) + +type wineTestOutput struct { + speech chan speechRequest + braille chan string +} + +func (output *wineTestOutput) Enqueue(request speechRequest) { output.speech <- request } +func (output *wineTestOutput) Interrupt() {} +func (output *wineTestOutput) EnqueueMessage(message string) bool { + output.braille <- message + return true +} + +func TestNativeSessionBusWineEvents(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus") + } + output := &wineTestOutput{speech: make(chan speechRequest, 32), braille: make(chan string, 32)} + handler := remoteSpeech{speech: output, braille: output} + owner := newWineEventPresenter(func(message string) bool { + return handler.presentMessage(message, "wine-accessible-event") + }) + defer owner.Close() + handler.wine = owner + service := remote.New(slog.New(slog.DiscardHandler), handler) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + defer service.Stop(context.Background()) + client, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer client.Close() + object := client.Object(remote.BusName, remote.ObjectPath) + send := func(event remote.AccessibleEvent, want bool) { + t.Helper() + var accepted bool + if err := object.CallWithContext(ctx, remote.InterfaceName+".PresentAccessibleEvent", 0, + event.SourceID, event.EventType, event.Name, event.Role, event.Value, event.States, + event.Description, event.Position, event.Count, event.WindowTitle).Store(&accepted); err != nil { + t.Fatal(err) + } + if accepted != want { + t.Fatalf("%s accepted=%v want=%v", event.EventType, accepted, want) + } + } + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", Value: "hello"}, true) + owner.flush() + for _, text := range []string{"l", "l", "e selected", "e deleted"} { + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=2 kind=5"}, true) + } + for _, text := range []string{"A", "a", "É", "é"} { + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8"}, true) + } + send(remote.AccessibleEvent{SourceID: "check", EventType: "focus", Name: "Option", Role: "check box", States: "not checked"}, true) + owner.flush() + send(remote.AccessibleEvent{SourceID: "check", EventType: "name", Name: "Renamed", Role: "check box", States: "not checked"}, true) + send(remote.AccessibleEvent{SourceID: "check", EventType: "state", Name: "Renamed", Role: "check box", States: "checked, unavailable"}, true) + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "stale"}, false) + owner.flush() + send(remote.AccessibleEvent{SourceID: "yes", EventType: "focus", Name: "Yes", Role: "client"}, true) + owner.flush() + owner.mu.Lock() + owner.lastTime = time.Now().Add(-time.Second) + owner.mu.Unlock() + send(remote.AccessibleEvent{SourceID: "no", EventType: "state", Name: "No", Role: "client"}, false) + send(remote.AccessibleEvent{SourceID: "yes", EventType: "state", Name: "Yes", Role: "client"}, false) + send(remote.AccessibleEvent{SourceID: "no", EventType: "focus", Name: "No", Role: "client"}, true) + owner.flush() + owner.Close() + for _, want := range []string{"edit, hello", "l", "l", "e selected", "e deleted", "A", "a", "É", "é", "Option, check box, not checked", "Renamed, check box, not checked", "Renamed, check box, checked, unavailable", "Yes", "No"} { + select { + case request := <-output.speech: + if request.Text != want || !request.Append { + t.Fatalf("speech=%+v want=%q", request, want) + } + if (want == "A" || want == "a" || want == "É" || want == "é") && !request.Character { + t.Fatalf("Wine typing lost character routing: %+v", request) + } + case <-ctx.Done(): + t.Fatal("timed out awaiting speech queue") + } + select { + case message := <-output.braille: + if message != want { + t.Fatalf("braille=%q want=%q", message, want) + } + case <-ctx.Done(): + t.Fatal("timed out awaiting braille queue") + } + } + if len(output.speech) != 0 || len(output.braille) != 0 { + t.Fatal("unexpected extra Wine output") + } +} diff --git a/internal/app/wine_events_test.go b/internal/app/wine_events_test.go new file mode 100644 index 0000000..5b4a22a --- /dev/null +++ b/internal/app/wine_events_test.go @@ -0,0 +1,285 @@ +package app + +import ( + "bytes" + "log/slog" + "slices" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/remote" +) + +func TestWineEventFocusBurstAndSelection(t *testing.T) { + var spoken []string + owner := newWineEventPresenter(func(message string) bool { + spoken = append(spoken, message) + return true + }) + defer owner.Close() + if !owner.Present(remote.AccessibleEvent{SourceID: "window:1", EventType: "focus", Name: "Loading", Role: "client", WindowTitle: "Game"}) { + t.Fatal("focus was rejected") + } + if !owner.Present(remote.AccessibleEvent{SourceID: "window:2", EventType: "focus", Name: "Play", Role: "button", Position: 2, Count: 3, WindowTitle: "Game"}) { + t.Fatal("replacement focus was rejected") + } + if len(spoken) != 0 { + t.Fatalf("focus burst spoke early: %v", spoken) + } + owner.flush() + if len(spoken) != 1 || spoken[0] != "Play, button, 2 of 3, Game" { + t.Fatalf("focus presentation = %v", spoken) + } + if owner.Present(remote.AccessibleEvent{SourceID: "other", EventType: "value", Value: "unfocused", WindowTitle: "Game"}) { + t.Fatal("unfocused value was accepted") + } + if !owner.Present(remote.AccessibleEvent{SourceID: "list:item", EventType: "selection", Name: "Race", Role: "list item", WindowTitle: "Game"}) { + t.Fatal("same-window selection was rejected") + } + if len(spoken) != 2 || spoken[1] != "Race, list item" { + t.Fatalf("selection presentation = %v", spoken) + } +} + +func TestWineEventClosedAndEmptyFiltering(t *testing.T) { + owner := newWineEventPresenter(func(string) bool { return true }) + if owner.Present(remote.AccessibleEvent{SourceID: "source", EventType: "unknown", Name: "ignored"}) { + t.Fatal("unknown event accepted") + } + if owner.Present(remote.AccessibleEvent{SourceID: "source", EventType: "focus", Role: "client"}) { + t.Fatal("empty client focus accepted") + } + owner.Close() + if owner.Present(remote.AccessibleEvent{SourceID: "source", EventType: "focus", Name: "after stop"}) { + t.Fatal("event accepted after stop") + } +} + +func TestWineButtonFocusLossDoesNotRepeatPreviousControl(t *testing.T) { + speech := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + presenter := remoteSpeech{speech: speech, braille: braille} + owner := newWineEventPresenter(func(message string) bool { + return presenter.presentMessage(message, "wine-accessible-event") + }) + defer owner.Close() + buttons := []string{"Yes", "No", "Cancel", "No"} + owner.Present(remote.AccessibleEvent{SourceID: buttons[0], EventType: "focus", Name: buttons[0], Role: "client"}) + owner.flush() + for index := 1; index < len(buttons); index++ { + previous, current := buttons[index-1], buttons[index] + // Native Wine sends the new button's state, the previous button's + // focus-loss state, then the new focus after a pause between Tabs. + owner.mu.Lock() + owner.lastTime = time.Now().Add(-time.Second) + owner.mu.Unlock() + if owner.Present(remote.AccessibleEvent{SourceID: current, EventType: "state", Name: current, Role: "client"}) { + t.Fatal("next button's state accepted before focus") + } + if owner.Present(remote.AccessibleEvent{SourceID: previous, EventType: "state", Name: previous, Role: "client"}) { + t.Fatal("unchanged previous button repeated on focus loss") + } + owner.Present(remote.AccessibleEvent{SourceID: current, EventType: "focus", Name: current, Role: "client"}) + owner.flush() + } + var spoken []string + for _, request := range speech.requests { + spoken = append(spoken, request.Text) + } + if !slices.Equal(spoken, buttons) || !slices.Equal(braille.text, buttons) { + t.Fatalf("spoken=%v braille=%v; want %v", spoken, braille.text, buttons) + } +} + +func TestWineEditorActionsReachSpeechAndBrailleInOrder(t *testing.T) { + speech := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + var log bytes.Buffer + presenter := remoteSpeech{speech: speech, braille: braille} + owner := newWineEventPresenter(func(message string) bool { + return presenter.presentMessage(message, "wine-accessible-event") + }) + owner.logger = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelDebug})) + defer owner.Close() + presenter.wine = owner + if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{ + SourceID: "42:text", EventType: "focus", Role: "edit", Value: "hello beta", + }) { + t.Fatal("editor focus rejected") + } + for _, text := range []string{"e", "l", "l", "beta", "second line", "s selected", "s unselected", "s deleted", "😀"} { + if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{ + SourceID: "42:text", EventType: "text", Name: text, + States: "start=1 end=2 kind=5", + }) { + t.Fatalf("editor action %q rejected", text) + } + } + owner.flush() + want := []string{"edit, hello beta", "e", "l", "l", "beta", "second line", "s selected", "s unselected", "s deleted", "😀"} + var spoken []string + for _, request := range speech.requests { + spoken = append(spoken, request.Text) + if !request.Append || request.Cause != "wine-accessible-event" { + t.Fatalf("unexpected editor speech policy: %+v", request) + } + } + if !slices.Equal(spoken, want) || !slices.Equal(braille.text, want) { + t.Fatalf("spoken=%v braille=%v; want %v", spoken, braille.text, want) + } + for _, diagnostic := range []string{"Wine editor feedback", "42:text", "start=1 end=2 kind=5", "s deleted"} { + if !strings.Contains(log.String(), diagnostic) { + t.Fatalf("missing debug diagnostic %q: %s", diagnostic, log.String()) + } + } +} + +func TestWineEditorRejectsStaleAndPasswordActions(t *testing.T) { + var spoken []string + var log bytes.Buffer + owner := newWineEventPresenter(func(message string) bool { spoken = append(spoken, message); return true }) + owner.logger = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelDebug})) + defer owner.Close() + text := remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "private text"} + if owner.Present(text) { + t.Fatal("text accepted without focus") + } + owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit"}) + owner.Present(remote.AccessibleEvent{SourceID: "menu", EventType: "focus", Name: "File", Role: "submenu"}) + if owner.Present(text) { + t.Fatal("text from editor accepted while menu owns focus") + } + owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", Value: "private text", Description: "password"}) + if owner.Present(text) { + t.Fatal("password action accepted") + } + owner.flush() + if !slices.Equal(spoken, []string{"edit, password"}) { + t.Fatalf("password focus/output = %v", spoken) + } + owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", States: "password"}) + if owner.Present(text) { + t.Fatal("MSAA password action accepted") + } + owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", Value: "blank"}) + if !owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "h"}) { + t.Fatal("ordinary editor did not recover after password focus") + } + if strings.Contains(log.String(), "private text") { + t.Fatalf("rejected/password text reached debug log: %s", log.String()) + } +} + +func TestWineFocusedNameAndStateChanges(t *testing.T) { + var spoken []string + owner := newWineEventPresenter(func(message string) bool { spoken = append(spoken, message); return true }) + defer owner.Close() + event := remote.AccessibleEvent{SourceID: "check", EventType: "focus", Name: "Option", Role: "check box", States: "not checked"} + owner.Present(event) + event.EventType, event.Name = "name", "Renamed option" + if !owner.Present(event) { + t.Fatal("focused name change rejected") + } + event.EventType, event.States = "state", "checked, unavailable" + if !owner.Present(event) { + t.Fatal("focused state change rejected") + } + owner.flush() + event.States = "not checked" + if !owner.Present(event) { + t.Fatal("unchecked state rejected") + } + for _, kind := range []string{"name", "state", "text"} { + event.SourceID, event.EventType = "background", kind + if owner.Present(event) { + t.Fatalf("background %s change accepted", kind) + } + } + want := []string{"Renamed option, check box, checked, unavailable", "Renamed option, check box, not checked"} + if !slices.Equal(spoken, want) { + t.Fatalf("spoken=%v want=%v", spoken, want) + } +} + +func TestWineEditorFlushInvalidatesOldTimer(t *testing.T) { + var spoken []string + owner := newWineEventPresenter(func(message string) bool { spoken = append(spoken, message); return true }) + defer owner.Close() + owner.Present(remote.AccessibleEvent{SourceID: "editor", EventType: "focus", Role: "edit"}) + owner.mu.Lock() + oldGeneration := owner.timerGeneration + owner.timer.Stop() + owner.mu.Unlock() + owner.Present(remote.AccessibleEvent{SourceID: "editor", EventType: "text", Name: "a"}) + owner.Present(remote.AccessibleEvent{SourceID: "menu", EventType: "focus", Name: "File"}) + owner.mu.Lock() + owner.timer.Stop() + owner.mu.Unlock() + // Model a timer that fired before Stop but was waiting for delivery. + owner.flush_generation(oldGeneration) + if !slices.Equal(spoken, []string{"edit", "a"}) { + t.Fatalf("stale timer delivered new focus: %v", spoken) + } + owner.flush() + if !slices.Equal(spoken, []string{"edit", "a", "File"}) { + t.Fatalf("menu focus lost: %v", spoken) + } +} + +func TestWineEditorCharacterSpeechRouting(t *testing.T) { + settings := config.DefaultSettings() + settings.UseCustomEchoVoice = true + live := &liveSettings{} + live.Store(settings) + speech := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + presenter := remoteSpeech{speech: speech, braille: braille, live: live} + owner := newWineEventPresenter(func(message string) bool { + return presenter.presentMessage(message, "wine-accessible-event") + }) + defer owner.Close() + presenter.wine = owner + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "focus", Role: "edit"}) + owner.flush() + for _, tc := range []struct { + text, states string + character, custom bool + }{ + {"A", "kind=8", true, true}, + {"a", "start=1 end=1 kind=8", true, true}, + {"É", "kind=8", true, true}, + {"𐐀", "kind=8", true, true}, + {"😀", "kind=8", true, true}, + {"space", "kind=8", true, true}, + {"new line", "kind=8", true, true}, + {"tab", "kind=8", true, true}, + {"A", "kind=5", true, false}, + {"A", "kind=7", true, true}, + {"ABC", "kind=5", false, false}, + {"A selected", "kind=2", false, false}, + {"A deleted", "kind=4", false, false}, + {"A", "kind=80", true, false}, + } { + if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{ + SourceID: "edit", EventType: "text", Name: tc.text, States: tc.states, + }) { + t.Fatalf("action rejected: %+v", tc) + } + request := speech.requests[len(speech.requests)-1] + if request.Text != tc.text || request.Character != tc.character || request.CustomEcho != tc.custom || + !request.Append || request.Cause != "wine-accessible-event" { + t.Fatalf("action %+v: request=%+v", tc, request) + } + if braille.text[len(braille.text)-1] != tc.text { + t.Fatalf("braille changed: %v", braille.text) + } + } + settings.UseCustomEchoForCharacter = false + live.Store(settings) + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "text", Name: "A", States: "kind=8"}) + if speech.requests[len(speech.requests)-1].CustomEcho { + t.Fatal("disabled custom character echo voice was used") + } +} diff --git a/internal/app/wine_review_capture.go b/internal/app/wine_review_capture.go new file mode 100644 index 0000000..ed39a4f --- /dev/null +++ b/internal/app/wine_review_capture.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/wine" + "strings" +) + +type wineReviewCapturer interface { + Capture(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) +} +type reviewCaptureRouter struct { + native review.TreeCapturer + wine wineReviewCapturer +} + +func reservedWineID(id atspi.ObjectID) bool { + return id.Path == "/wine" || strings.HasPrefix(id.Path, "/wine/") +} +func (r *reviewCaptureRouter) FreshReviewWindow(id atspi.ObjectID) bool { return reservedWineID(id) } +func (r *reviewCaptureRouter) NativeCapturerFor(id atspi.ObjectID) review.TreeCapturer { + if reservedWineID(id) { + return nil + } + return r.native +} +func (r *reviewCaptureRouter) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) { + return r.native.CaptureDesktop(ctx) +} +func (r *reviewCaptureRouter) CaptureReviewWindow(ctx context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + if !reservedWineID(id) { + return r.native.CaptureReviewWindow(ctx, id) + } + if r.wine == nil || !wine.IsReviewID(id) { + return nil, wine.ErrReviewUnavailable + } + tree, err := r.wine.Capture(ctx, id) + if errors.Is(err, wine.ErrReviewLimited) { + err = review.ErrLimitedReview + } + return tree, err +} +func (r *reviewCaptureRouter) CaptureDocument(ctx context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + if reservedWineID(id) { + return nil, wine.ErrReviewUnavailable + } + return r.native.CaptureDocument(ctx, id) +} +func (r *reviewCaptureRouter) CaptureDocumentText(ctx context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + if reservedWineID(id) { + return nil, wine.ErrReviewUnavailable + } + return r.native.CaptureDocumentText(ctx, id) +} diff --git a/internal/app/wine_review_capture_test.go b/internal/app/wine_review_capture_test.go new file mode 100644 index 0000000..39dab39 --- /dev/null +++ b/internal/app/wine_review_capture_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/wine" + "testing" +) + +type reviewNativeSpy struct { + calls []string + ids []atspi.ObjectID +} + +func (s *reviewNativeSpy) record(method string, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + s.calls = append(s.calls, method) + s.ids = append(s.ids, id) + return nil, nil +} +func (s *reviewNativeSpy) CaptureDesktop(context.Context) (*atspi.AccessibleTree, error) { + return s.record("desktop", atspi.ObjectID{}) +} +func (s *reviewNativeSpy) CaptureReviewWindow(_ context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + return s.record("review", id) +} +func (s *reviewNativeSpy) CaptureDocument(_ context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + return s.record("document", id) +} +func (s *reviewNativeSpy) CaptureDocumentText(_ context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + return s.record("text", id) +} + +type reviewWineSpy struct { + calls int + err error +} + +func (s *reviewWineSpy) Capture(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) { + s.calls++ + return nil, s.err +} +func TestReviewCaptureRouterNativeDelegation(t *testing.T) { + native := &reviewNativeSpy{} + r := &reviewCaptureRouter{native: native, wine: &reviewWineSpy{}} + id := atspi.NewObjectID(":1.2", "/linux") + r.CaptureDesktop(context.Background()) + r.CaptureReviewWindow(context.Background(), id) + r.CaptureDocument(context.Background(), id) + r.CaptureDocumentText(context.Background(), id) + if len(native.calls) != 4 || native.calls[0] != "desktop" || native.calls[1] != "review" || native.calls[2] != "document" || native.calls[3] != "text" || r.FreshReviewWindow(id) || r.NativeCapturerFor(id) != native { + t.Fatal("native delegation changed") + } + for _, got := range native.ids[1:] { + if got != id { + t.Fatal("native identity changed") + } + } +} +func TestReviewCaptureRouterWineIDsNeverReachATSPINativeCalls(t *testing.T) { + native := &reviewNativeSpy{} + provider := &reviewWineSpy{err: wine.ErrReviewLimited} + r := &reviewCaptureRouter{native: native, wine: provider} + ids := []atspi.ObjectID{wine.ReviewID(":1.2", "instance", "window", "input"), atspi.NewObjectID(":1.2", "/wine/malformed"), atspi.NewObjectID(":1.2", "/wine")} + for _, id := range ids { + if !r.FreshReviewWindow(id) || r.NativeCapturerFor(id) != nil { + t.Fatal("Wine optional native capability exposed") + } + if _, err := r.CaptureDocument(context.Background(), id); !errors.Is(err, wine.ErrReviewUnavailable) { + t.Fatal(err) + } + if _, err := r.CaptureDocumentText(context.Background(), id); !errors.Is(err, wine.ErrReviewUnavailable) { + t.Fatal(err) + } + _, err := r.CaptureReviewWindow(context.Background(), id) + if wine.IsReviewID(id) && !errors.Is(err, review.ErrLimitedReview) { + t.Fatal("limit message mapping lost") + } + } + if len(native.calls) != 0 || provider.calls != 1 { + t.Fatalf("native Wine calls: %+v", native.calls) + } +} diff --git a/internal/app/wine_review_focus.go b/internal/app/wine_review_focus.go new file mode 100644 index 0000000..315cd04 --- /dev/null +++ b/internal/app/wine_review_focus.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/wine" + "log/slog" +) + +type wineReviewFocusRegistry interface { + Focuses() []wine.ReviewFocusNotice + Current(atspi.ObjectID) bool +} + +// Only the event loop calls this controller. Inputs are copied metadata; +// foreground queries read the adapter's atomic cache and perform no native I/O. +type wineReviewFocusController struct { + registry wineReviewFocusRegistry + logger *slog.Logger + state *eventLoopState + foreground foregroundMonitor + titles windowTitleSource + gate *focusGate + worker *review.Worker + clear func() + active wine.ReviewFocusNotice + epoch uint64 +} + +func wineReviewBoundsMatch(bounds atspi.Rectangle, client xorg.Foreground) bool { + return client.Known && client.Window != 0 && bounds.Valid() && ((int64(bounds.X) == int64(client.X) && int64(bounds.Y) == int64(client.Y) && int64(bounds.Width) == int64(client.Width) && int64(bounds.Height) == int64(client.Height)) || + (int64(bounds.X) == int64(client.OuterX) && int64(bounds.Y) == int64(client.OuterY) && int64(bounds.Width) == int64(client.OuterWidth) && int64(bounds.Height) == int64(client.OuterHeight))) +} +func (c *wineReviewFocusController) eligible(notice wine.ReviewFocusNotice, fg atspi.ForegroundState, title atspi.ForegroundTitle) bool { + metadata := title.Title + if !fg.Client.Known || fg.Client.Window == 0 || title.Epoch != fg.Epoch || metadata.Window != fg.Client.Window || !metadata.Wine || !metadata.Focused || !equivalentWindowTitles(metadata.Title, notice.Title) { + return false + } + if metadata.TitleWindow == metadata.Window { + return wineReviewBoundsMatch(notice.Bounds, fg.Client) + } + if metadata.TitleWindow == 0 || metadata.Client.Window != metadata.TitleWindow { + return false + } + if wineReviewBoundsMatch(notice.Bounds, metadata.Client) { + return true + } + // Wine's virtual desktop uses desktop-local screen coordinates. Corroborate + // a translated rectangle against the actual focused X11 child, not a title. + return int64(notice.Bounds.X)+int64(fg.Client.X) == int64(metadata.Client.X) && int64(notice.Bounds.Y)+int64(fg.Client.Y) == int64(metadata.Client.Y) && + int64(notice.Bounds.Width) == int64(metadata.Client.Width) && int64(notice.Bounds.Height) == int64(metadata.Client.Height) && notice.Bounds.Valid() +} +func (c *wineReviewFocusController) changed() { + if c == nil || c.registry == nil || c.worker == nil || c.foreground == nil || c.titles == nil || c.gate.Locked() { + if c != nil { + c.retire() + } + return + } + fg, title := c.foreground.Foreground(), c.titles.ForegroundTitle() + var selected wine.ReviewFocusNotice + count := 0 + for _, notice := range c.registry.Focuses() { + eligible := c.eligible(notice, fg, title) + if !eligible && c.logger != nil { + c.logger.Debug("Wine review foreground mismatch", "sender", notice.Sender, + "window", notice.Window, "focus", notice.Focus, "revision", notice.Revision, + "source_bounds", notice.Bounds, "native_foreground", fg.Client, + "foreground_epoch", fg.Epoch, "title_epoch", title.Epoch, "title_metadata", title.Title) + } + if eligible { + selected = notice + count++ + } + } + if count != 1 || !c.registry.Current(selected.ID()) { + c.retire() + return + } + c.gate.SetSuspended(false) + transition := c.active.ID() != selected.ID() || c.active.Revision != selected.Revision || c.active.Protected != selected.Protected || c.epoch != fg.Epoch + if transition { + if c.clear != nil { + c.clear() + } else { + c.worker.ClearFocus() + } + c.worker.SyncFocus(selected.ID()) + } + c.active, c.epoch = selected, fg.Epoch + c.state.current = atspi.Snapshot{BusName: selected.Sender, Path: selected.ID().Path, Role: "text"} + if selected.Protected { + c.state.current.Role = "password text" + } + c.state.focusedID = selected.ID() + c.state.owner = windowOwner{applicationBus: selected.Sender, window: wine.ReviewID(selected.Sender, selected.Instance, selected.Window, selected.Window), focus: selected.ID(), activated: true, title: selected.Title} +} +func (c *wineReviewFocusController) foregroundChanged() { c.changed() } +func (c *wineReviewFocusController) retire() { + if c == nil || c.active.Sender == "" { + return + } + if c.worker != nil && c.worker.Focus() == c.active.ID() { + if c.clear != nil { + c.clear() + } else { + c.worker.ClearFocus() + } + } + if c.state != nil && c.state.owner.applicationBus == c.active.Sender { + c.state.owner.clear() + } + c.active = wine.ReviewFocusNotice{} + c.epoch = 0 +} +func (c *wineReviewFocusController) owns(id atspi.ObjectID) bool { + return c != nil && c.active.Sender != "" && c.active.ID() == id && c.registry.Current(id) +} +func (c *wineReviewFocusController) resultCurrent(result review.Result) bool { + c.changed() + return c.owns(result.FlatFocus) && flatReviewResultCurrent(c.worker, result) +} diff --git a/internal/app/wine_review_focus_test.go b/internal/app/wine_review_focus_test.go new file mode 100644 index 0000000..59a58d0 --- /dev/null +++ b/internal/app/wine_review_focus_test.go @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "context" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/wine" + "log/slog" + "testing" + "time" +) + +type reviewFocusRegistry struct{ notices []wine.ReviewFocusNotice } + +func (r *reviewFocusRegistry) Focuses() []wine.ReviewFocusNotice { + return append([]wine.ReviewFocusNotice(nil), r.notices...) +} + +func TestWineReviewSpeechBrailleAndSpelling(t *testing.T) { + c, _, _, worker := reviewFocusSetup(t) + c.changed() + queue := &fakeSpeechQueue{spoken: make(chan string, 10), requests: make(chan speechRequest, 10)} + frames := make(chan braille.Frame, 10) + handler := &eventReviewHandler{state: c.state, logger: slog.New(slog.DiscardHandler), speech: queue, objectReview: worker, wineReview: c, + brailleOutput: frameRecorder{frames: frames}, syncForeground: c.foregroundChanged} + for _, test := range []struct { + unit review.FlatUnit + style review.FlatSpeech + text string + }{ + {review.Line, review.FlatSpeak, "alpha beta"}, {review.Word, review.FlatSpell, "cat"}, {review.Character, review.FlatPhonetic, "c"}, + } { + handler.handle(review.Result{FlatUnit: test.unit, FlatSpeech: test.style, FlatFocus: worker.Focus(), DocumentEpoch: worker.DocumentEpoch(), FlatPosition: review.FlatPosition{Text: test.text}}) + request := <-queue.requests + if request.Cause != "flat-review" || (test.style != review.FlatSpeak && len(request.Spelling) == 0) { + t.Fatalf("shared presentation lost: %+v", request) + } + select { + case frame := <-frames: + if frame.Text != test.text || len(frame.Routes) != 0 || frame.Document != nil { + t.Fatalf("braille: %+v", frame) + } + case <-time.After(time.Second): + t.Fatal("Wine review braille frame missing") + } + } +} + +func TestWineReviewLockAndXTermHandoff(t *testing.T) { + c, _, _, worker := reviewFocusSetup(t) + c.gate = newFocusGate(nil) + c.gate.SetSuspended(true) + c.changed() + if c.gate.Suspended() || !worker.Focus().Valid() { + t.Fatal("Wine focus failed to resume review grabs") + } + c.gate.secure.Store(true) + c.changed() + if worker.Focus().Valid() { + t.Fatal("locked session kept Wine review focus") + } +} +func (r *reviewFocusRegistry) Current(id atspi.ObjectID) bool { + for _, n := range r.notices { + if n.ID() == id { + return true + } + } + return false +} + +type reviewFocusMonitor struct { + state atspi.ForegroundState + title atspi.ForegroundTitle +} + +func (m *reviewFocusMonitor) Foreground() atspi.ForegroundState { return m.state } +func (m *reviewFocusMonitor) ForegroundChanges() <-chan struct{} { return nil } +func (m *reviewFocusMonitor) ForegroundTitle() atspi.ForegroundTitle { return m.title } +func (m *reviewFocusMonitor) TitleChanges() <-chan struct{} { return nil } +func reviewFocusSetup(t *testing.T) (*wineReviewFocusController, *reviewFocusRegistry, *reviewFocusMonitor, *review.Worker) { + t.Helper() + n := wine.ReviewFocusNotice{Sender: ":1.20", Path: wine.ReviewObjectPath, Instance: "instance", Window: "window", Focus: "empty", Revision: 1, Title: "Same title", Role: "window", Bounds: atspi.Rectangle{X: -20, Y: 10, Width: 400, Height: 300}} + registry := &reviewFocusRegistry{notices: []wine.ReviewFocusNotice{n}} + monitor := &reviewFocusMonitor{state: atspi.ForegroundState{Epoch: 1, Client: xorg.Foreground{Known: true, Window: 20, X: -20, Y: 10, Width: 400, Height: 300}}} + monitor.title = atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: n.Title, Application: "fixture.exe", Wine: true, Focused: true, Client: monitor.state.Client}} + worker, err := review.NewWorker(&fakeAdapter{}, time.Second) + if err != nil { + t.Fatal(err) + } + state := &eventLoopState{} + controller := &wineReviewFocusController{registry: registry, state: state, foreground: monitor, titles: monitor, worker: worker, clear: func() { state.current = atspi.Snapshot{}; state.focusedID = atspi.ObjectID{}; worker.ClearFocus() }} + return controller, registry, monitor, worker +} +func TestWineReviewIdenticalTitlesDoNotClaimForeground(t *testing.T) { + c, r, m, w := reviewFocusSetup(t) + r.notices[0].Bounds.X = 500 + c.changed() + if w.Focus().Valid() { + t.Fatal("title alone claimed focus") + } + r.notices[0].Bounds.X = -20 + duplicate := r.notices[0] + duplicate.Sender = ":1.21" + r.notices = append(r.notices, duplicate) + c.changed() + if w.Focus().Valid() { + t.Fatal("ambiguous providers claimed focus") + } + r.notices = r.notices[:1] + m.title.Title.Wine = false + c.changed() + if w.Focus().Valid() { + t.Fatal("Linux client claimed by stale Wine provider") + } + m.title.Title.Wine = true + c.changed() + if w.Focus() != r.notices[0].ID() { + t.Fatal("unique geometry did not establish focus") + } +} +func TestWineReviewForegroundAndVirtualDesktop(t *testing.T) { + c, r, m, w := reviewFocusSetup(t) + c.changed() + if w.Focus() != r.notices[0].ID() { + t.Fatal("negative coordinate focus missing") + } + m.state.Epoch++ + c.foregroundChanged() + if w.Focus().Valid() { + t.Fatal("old metadata epoch reused") + } + m.title.Epoch = m.state.Epoch + m.state.Client.Width = 800 + m.state.Client.Height = 600 + m.title.Title.TitleWindow = 21 + m.title.Title.Client = xorg.Foreground{Known: true, Window: 21, X: -20, Y: 10, Width: 400, Height: 300} + c.foregroundChanged() + if w.Focus() != r.notices[0].ID() { + t.Fatal("focused desktop child not matched") + } + m.title.Title.Focused = false + c.changed() + if w.Focus().Valid() { + t.Fatal("fallback desktop title claimed focus") + } +} +func TestWineReviewDropsOldResultsAndRestarts(t *testing.T) { + c, r, m, w := reviewFocusSetup(t) + c.changed() + old := review.Result{FlatUnit: review.Line, FlatFocus: w.Focus(), DocumentEpoch: w.DocumentEpoch()} + if !c.resultCurrent(old) { + t.Fatal("current result rejected") + } + c.changed() + if !c.resultCurrent(old) { + t.Fatal("duplicate retired cursor") + } + r.notices[0].Revision++ + c.changed() + if c.resultCurrent(old) { + t.Fatal("revision retained queued result") + } + old = review.Result{FlatUnit: review.Line, FlatFocus: w.Focus(), DocumentEpoch: w.DocumentEpoch()} + r.notices = nil + c.changed() + if c.resultCurrent(old) || w.Focus().Valid() { + t.Fatal("dead provider remained active") + } + m.state.Client.Known = false + c.changed() + if w.Focus().Valid() { + t.Fatal("unknown foreground accepted") + } + // Native ownership is restored by the normal native event path after retirement. + native := atspi.NewObjectID(":1.8", "/linux") + w.SyncFocus(native) + c.changed() + if w.Focus() != native { + t.Fatal("Wine retirement erased restored Linux focus") + } +} +func TestWineReviewEmptyFocusCanReachOutput(t *testing.T) { + c, r, _, w := reviewFocusSetup(t) + n := r.notices[0] + root := wine.ReviewID(n.Sender, n.Instance, n.Window, n.Window) + empty := n.ID() + output := wine.ReviewID(n.Sender, n.Instance, n.Window, "output") + geometry := func(b atspi.Rectangle) atspi.Geometry { + return atspi.Geometry{Bounds: b, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + } + tree, err := atspi.NewAccessibleTree(root, []atspi.TreeNode{ + {Snapshot: atspi.Snapshot{BusName: root.BusName, Path: root.Path, Role: "window"}, Geometry: geometry(n.Bounds), Children: []atspi.ObjectID{empty, output}}, + {Snapshot: atspi.Snapshot{BusName: empty.BusName, Path: empty.Path, Role: "text"}, Geometry: geometry(atspi.Rectangle{X: 0, Y: 40, Width: 200, Height: 20}), Text: atspi.VisibleText{Known: true, CaretKnown: true}}, + {Snapshot: atspi.Snapshot{BusName: output.BusName, Path: output.Path, Role: "text"}, Geometry: geometry(atspi.Rectangle{X: 0, Y: 100, Width: 200, Height: 20}), Text: atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: 11, Text: "alpha beta!", Bounds: atspi.Rectangle{X: 0, Y: 100, Width: 200, Height: 20}}}}}, + }) + if err != nil { + t.Fatal(err) + } + c.changed() + cursor := new(review.FlatCursor) + if err = cursor.Refresh(context.Background(), tree, w.Focus()); err != nil { + t.Fatal(err) + } + _, _, err = cursor.Read(review.Line, 0) + position, _, err := cursor.Read(review.Line, 1) + if err != nil || position.Text != "alpha beta!" { + t.Fatalf("empty anchor: %+v %v", position, err) + } +} diff --git a/internal/app/wine_review_native_test.go b/internal/app/wine_review_native_test.go new file mode 100644 index 0000000..475944e --- /dev/null +++ b/internal/app/wine_review_native_test.go @@ -0,0 +1,369 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "bufio" + "context" + "encoding/json" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/remote" + "git.stormux.org/storm/cthulhu/internal/review" + "git.stormux.org/storm/cthulhu/internal/wine" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +type wineNativeOutput struct { + requests chan speechRequest + frames chan braille.Frame +} + +func (o *wineNativeOutput) Enqueue(request speechRequest) { o.requests <- request } +func (o *wineNativeOutput) Interrupt() {} +func (o *wineNativeOutput) EnqueueFrame(frame braille.Frame) { o.frames <- frame } + +func TestNativeWineReviewPhysicalKeys(t *testing.T) { + if os.Getenv("CTHULHU_WINE_REVIEW_BUILD") == "" { + t.Skip("requires isolated Wine build/display/session") + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second) + defer cancel() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + registry := wine.NewReviewRegistry(logger) + if err := registry.Start(ctx); err != nil { + t.Fatal(err) + } + defer registry.Stop(context.Background()) + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := commands.Start(ctx); err != nil { + t.Fatal(err) + } + defer commands.Stop(context.Background()) + gate := newFocusGate(commands) + worker, err := review.NewWorker(&reviewCaptureRouter{native: adapter, wine: registry}, time.Second) + if err != nil { + t.Fatal(err) + } + if err = worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + live := &liveSettings{wineReview: registry} + live.Store(config.DefaultSettings()) + output := &wineNativeOutput{requests: make(chan speechRequest, 256), frames: make(chan braille.Frame, 256)} + queue := outputQueue{speech: output, braille: output, live: live} + helperEvents := newWineEventPresenter(func(text string) bool { + output.Enqueue(speechRequest{Text: text, Cause: "wine-accessible-event"}) + return true + }) + defer helperEvents.Close() + service := remote.New(logger, remoteSpeech{speech: output, wine: helperEvents, wineReview: registry}) + if err = service.Start(ctx); err != nil { + t.Fatal(err) + } + defer service.Stop(context.Background()) + eventDone, commandDone := make(chan error, 1), make(chan error, 1) + go func() { + eventDone <- processEventsWithAttributes(ctx, logger, queue, adapter, nil, nil, worker, 0, live, nil, gate, nil, nil) + }() + go func() { + commandDone <- (commandRuntime{logger: logger, commands: commands, focusChanges: commands.FocusChanges(), focusGate: gate, speechOutput: queue, objectReview: worker, live: live, stopRun: cancel}).run(ctx) + }() + defer func() { + cancel() + if err := <-eventDone; err != nil { + t.Error(err) + } + if err := <-commandDone; err != nil { + t.Error(err) + } + }() + driver := exec.CommandContext(ctx, "python3", "-u", "../../tests/wine_review_x11.py") + stdin, err := driver.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := driver.StdoutPipe() + if err != nil { + t.Fatal(err) + } + driver.Stderr = os.Stderr + if err = driver.Start(); err != nil { + t.Fatal(err) + } + defer func() { + stdin.Close() + if err := driver.Wait(); err != nil && ctx.Err() == nil { + t.Error(err) + } + }() + scanner := bufio.NewScanner(stdout) + ready := func() { + t.Helper() + if !scanner.Scan() || scanner.Text() != "{\"ready\": true}" { + t.Fatalf("physical fixture not ready: %s", scanner.Text()) + } + } + ready() + send := func(command map[string]any) { + t.Helper() + data, _ := json.Marshal(command) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + ready() + } + waitFocus := func() { + t.Helper() + deadline := time.Now().Add(6 * time.Second) + for { + matched := false + probe := &wineReviewFocusController{} + for _, notice := range registry.Focuses() { + if notice.ID() == worker.Focus() && probe.eligible(notice, adapter.Foreground(), adapter.ForegroundTitle()) { + matched = true + } + } + if matched { + return + } + if time.Now().After(deadline) { + t.Fatalf("Wine focus unavailable; notices=%+v foreground=%+v title=%+v", registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle()) + } + time.Sleep(10 * time.Millisecond) + } + } + waitFocus() + waitCaptureReady := func() { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + id := worker.Focus() + if _, err := registry.Capture(ctx, id); err == nil && registry.Current(id) && worker.Focus() == id { + return + } + if time.Now().After(deadline) { + t.Fatal("new native provider never reached a consistent capture") + } + time.Sleep(10 * time.Millisecond) + } + } + check := func(symbols []uint32, want string, spelling bool) { + t.Helper() + // Normal Linux focus frames can remain queued after switching back to + // Wine. Inspect only frames produced for this physical command. + for len(output.frames) > 0 { + <-output.frames + } + started := time.Now() + send(map[string]any{"action": "keys", "symbols": symbols}) + for { + select { + case request := <-output.requests: + if request.Cause != "flat-review" { + continue + } + if request.Text != want || (spelling && len(request.Spelling) == 0) { + t.Fatalf("keys %x: speech=%+v want=%q", symbols, request, want) + } + if spelling && want == "b" && request.Spelling[0] != "bravo" { + t.Fatalf("phonetic letter: %+v", request.Spelling) + } + t.Logf("keys %x: %q queued in %s", symbols, want, time.Since(started)) + if strings.Contains(request.Text, "private-review-secret") || strings.Contains(request.Text, "hidden-review-secret") { + t.Fatal("excluded text reached speech") + } + goto frame + case <-time.After(2 * time.Second): + tree, captureErr := registry.Capture(ctx, worker.Focus()) + t.Logf("capture=%+v error=%v focus=%+v notices=%+v foreground=%+v title=%+v", tree, captureErr, worker.Focus(), registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle()) + t.Fatalf("physical keys %x did not produce %q", symbols, want) + } + } + frame: + if !wine.IsReviewID(worker.Focus()) { + return + } + for { + select { + case f := <-output.frames: + if f.Text == "" { + continue + } + if len(f.Routes) != 0 || f.Document != nil || strings.Contains(f.Text, "private-review-secret") { + t.Fatalf("unsafe native routing: %+v", f) + } + return + case <-time.After(time.Second): + t.Fatal("review braille output missing") + } + } + } + check([]uint32{0xff97}, "alpha beta", false) + check([]uint32{0xff9a}, "second line", false) + check([]uint32{0xff95}, "alpha beta", false) + check([]uint32{0xff9d}, "alpha ", false) + check([]uint32{0xff9d}, "alpha ", true) + check([]uint32{0xff9d}, "alpha ", true) + check([]uint32{0xff98}, "beta", false) + check([]uint32{0xff99}, "b", false) + check([]uint32{0xff99}, "b", true) + check([]uint32{0xff9b}, "e", false) + check([]uint32{0xff9c}, "b", false) + check([]uint32{0xff63, 0xff9c}, "a", false) + check([]uint32{0xff63, 0xff98}, "line", false) + check([]uint32{0xff63, 0xff96}, "beta", false) + check([]uint32{0xff63, 0xff95}, "Command:", false) + check([]uint32{0xff63, 0xff9a}, "Open & close", false) + check([]uint32{0xff63, 0xff95}, "Command:", false) + check([]uint32{0xff9a}, "alpha beta", false) + check([]uint32{0xff98}, "beta", false) + send(map[string]any{"action": "check"}) + if os.Getenv("CTHULHU_WINE_REVIEW_WM") == "i3" { + send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}}) + send(map[string]any{"action": "check", "check": "--check-right"}) + return + } + send(map[string]any{"action": "configure", "mode": 6}) + time.Sleep(200 * time.Millisecond) + check([]uint32{0xff9d}, "zeta", false) + // Seek the first text line from the window's caption, then verify an update + // within the same control without a native caret change. + check([]uint32{0xff63, 0xff95}, "Command:", false) + check([]uint32{0xff9a}, "omega zeta native & item", false) + send(map[string]any{"action": "check"}) + send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}}) + send(map[string]any{"action": "check", "check": "--check-right"}) + send(map[string]any{"action": "configure", "mode": 8}) + positionDeadline := time.Now().Add(5 * time.Second) + for { + moved := false + for _, notice := range registry.Focuses() { + if notice.Bounds.X < 0 { + moved = true + } + } + if moved { + break + } + if time.Now().After(positionDeadline) { + t.Fatal("native window movement was not published") + } + time.Sleep(10 * time.Millisecond) + } + waitFocus() + check([]uint32{0xff97}, "alpha beta native & item", false) + oldFocus := worker.Focus() + send(map[string]any{"action": "restart-helper"}) + deadline := time.Now().Add(5 * time.Second) + for worker.Focus() == oldFocus || !wine.IsReviewID(worker.Focus()) { + if time.Now().After(deadline) { + t.Fatal("helper restart kept old review lifetime") + } + time.Sleep(10 * time.Millisecond) + } + waitFocus() + check([]uint32{0xff97}, "alpha beta native & item", false) + // Restart the actual service owner and registry connections. The helper must + // register again and authorize the newly owned connection for capture. + if err := service.Stop(context.Background()); err != nil { + t.Fatal(err) + } + if err := registry.Stop(context.Background()); err != nil { + t.Fatal(err) + } + worker.ClearFocus() + if err := registry.Start(ctx); err != nil { + t.Fatal(err) + } + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + waitFocus() + check([]uint32{0xff97}, "alpha beta native & item", false) + // A native Linux foreground change retires a queued Wine capture, while its + // blocked MSAA worker is still waiting in the application's window procedure. + send(map[string]any{"action": "configure", "mode": 10}) + waitFocus() + send(map[string]any{"action": "keys", "symbols": []uint32{0xff97}}) + send(map[string]any{"action": "linux"}) + deadline = time.Now().Add(5 * time.Second) + for !worker.Focus().Valid() || wine.IsReviewID(worker.Focus()) { + if time.Now().After(deadline) { + t.Fatalf("Linux focus not restored: %+v", worker.Focus()) + } + time.Sleep(10 * time.Millisecond) + } + check([]uint32{0xff97}, "Linux review ready", false) + lateDeadline := time.After(2 * time.Second) +late: + for { + select { + case request := <-output.requests: + if request.Cause == "flat-review" { + t.Fatalf("late Wine review result: %+v", request) + } + case <-lateDeadline: + break late + } + } + send(map[string]any{"action": "wine"}) + waitFocus() + firstPrefix := worker.Focus() + send(map[string]any{"action": "second-prefix"}) + deadline = time.Now().Add(5 * time.Second) + for worker.Focus().BusName == firstPrefix.BusName || !wine.IsReviewID(worker.Focus()) { + if time.Now().After(deadline) { + t.Fatal("identical titles shared a provider across prefixes") + } + time.Sleep(10 * time.Millisecond) + } + waitFocus() + if worker.Focus().BusName == firstPrefix.BusName { + t.Fatal("identical titles shared a provider across prefixes") + } + // The 32-bit hook host can publish initial lifetime events after its X11 + // window appears. Handshake on a consistent snapshot before this physical + // key; every key still captures again. Do not warm the stalled-call phase. + waitCaptureReady() + check([]uint32{0xff97}, "alpha beta native & item", false) + send(map[string]any{"action": "wine"}) + deadline = time.Now().Add(5 * time.Second) + for worker.Focus() != firstPrefix { + if time.Now().After(deadline) { + t.Fatal("return to first prefix lost its native lifetime") + } + time.Sleep(10 * time.Millisecond) + } + waitFocus() + if worker.Focus() != firstPrefix { + t.Fatal("return to first prefix lost its native lifetime") + } + check([]uint32{0xff97}, "alpha beta native & item", false) + oldDesktopFocus := worker.Focus() + send(map[string]any{"action": "virtual-desktop"}) + deadline = time.Now().Add(6 * time.Second) + for !wine.IsReviewID(worker.Focus()) || worker.Focus().BusName == oldDesktopFocus.BusName { + if time.Now().After(deadline) { + t.Fatal("virtual desktop restart kept the old helper lifetime") + } + time.Sleep(10 * time.Millisecond) + } + waitFocus() + waitCaptureReady() + check([]uint32{0xff97}, "alpha beta native & item", false) +} diff --git a/internal/app/wine_window_titles.go b/internal/app/wine_window_titles.go new file mode 100644 index 0000000..0cee47e --- /dev/null +++ b/internal/app/wine_window_titles.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import "git.stormux.org/storm/cthulhu/internal/atspi" + +type wineWindowTitle struct { + text string + foreground atspi.ForegroundTitle +} + +// Wine control output stays in its presenter. Its titles use the same +// event-loop policy as native title changes, through a copied-data mailbox. +func connectWineWindowTitles(owner *wineEventPresenter, live *liveSettings, adapter Adapter) { + if live == nil { + return + } + live.wineTitles = make(chan wineWindowTitle, 1) + source, _ := adapter.(windowTitleSource) + owner.windowTitle = func(text string) { + update := wineWindowTitle{text: text} + if source != nil { + update.foreground = source.ForegroundTitle() + } + select { + case <-live.wineTitles: + default: + } + select { + case live.wineTitles <- update: + default: + } + } +} + +func (titles *windowTitleController) wineChanged(update wineWindowTitle) { + native := update.foreground.Title + // A helper may deliver a delayed focus after another program took the + // foreground. X11 metadata is authoritative when it is available. + if native.Window != 0 && !equivalentWindowTitles(native.Title, update.text) { + return + } + if titles.foreground != nil { + current := titles.foreground.Foreground() + if current.Client.Known && current.Client.Window == 0 { + return + } + } + changed := titles.lastTitle != "" + if titles.pending != nil { + changed = titles.pending.changed + } + if native.Window != 0 && (update.foreground.Epoch != titles.lastNative.Epoch || + native.Window != titles.lastNative.Title.Window || native.TitleWindow != titles.lastNative.Title.TitleWindow) { + changed = false + } + titles.queue(pendingWindowTitle{text: update.text, native: native, + window: titles.state.owner.window, epoch: update.foreground.Epoch, changed: changed, + application: titles.application}) +} diff --git a/internal/app/xterm_handoff_test.go b/internal/app/xterm_handoff_test.go new file mode 100644 index 0000000..3e4b457 --- /dev/null +++ b/internal/app/xterm_handoff_test.go @@ -0,0 +1,262 @@ +package app + +import ( + "context" + "log/slog" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/notification" +) + +type commandGrabRecorder struct { + commands chan input.CommandEvent + failures chan error + mutex sync.Mutex + values []bool +} + +func (recorder *commandGrabRecorder) Commands() <-chan input.CommandEvent { + return recorder.commands +} + +func (recorder *commandGrabRecorder) LockStates() <-chan input.LockState { return nil } +func (recorder *commandGrabRecorder) Failures() <-chan error { return recorder.failures } +func (recorder *commandGrabRecorder) Start(context.Context) error { return nil } +func (recorder *commandGrabRecorder) Stop(context.Context) error { return nil } + +func (recorder *commandGrabRecorder) SetCommandGrabs(enabled bool) { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + recorder.values = append(recorder.values, enabled) +} + +func (recorder *commandGrabRecorder) grabs() []bool { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + return append([]bool(nil), recorder.values...) +} + +func TestXTermFocusSuspendsAndResumesPresentationAndCommandGrabs(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + commands := &commandGrabRecorder{ + commands: make(chan input.CommandEvent, 1), + failures: make(chan error, 1), + } + gate := newFocusGate(commands) + gate.speech = speech + history := notification.NewPresenter() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + defer close(completed) + processEventsWithInput( + ctx, + slog.New(slog.DiscardHandler), + outputQueue{speech: gatedSpeech{speech: speech, gate: gate}}, + adapter, + nil, + nil, + 0, + nil, + nil, + gate, + history, + ) + }() + + home := atspi.NewSnapshot("/home", "Home", "push button", "", "") + home.ApplicationName = "Browser" + terminal := atspi.NewSnapshot("/terminal", "XTerm", "terminal", "", "Prompt") + terminal.ApplicationName = "xterm" + notification := atspi.NewSnapshot("/notification", "", "frame", "", "") + notification.ApplicationName = "xfce4-notifyd" + notification.Text = "Notification ready" + browser := atspi.NewSnapshot("/browser", "Browser", "push button", "", "") + browser.ApplicationName = "Browser" + + xt := atspi.NewEvent(atspi.FocusedStateEvent, terminal) + xt.Detail1 = 1 + expectSpeech := func(want string) { + t.Helper() + select { + case got := <-speech.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for speech %q", want) + } + } + expectInterrupt := func() { + t.Helper() + select { + case <-speech.interrupt: + case <-time.After(time.Second): + t.Fatal("XTerm focus did not interrupt Cthulhu speech") + } + } + waitForGrabs := func(want []bool) { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + if got := commands.grabs(); equalBools(got, want) { + return + } + if time.Now().After(deadline) { + t.Fatalf("command grabs = %v, want %v", commands.grabs(), want) + } + time.Sleep(time.Millisecond) + } + } + + adapter.events <- atspi.NewEvent(atspi.FocusEvent, home) + expectSpeech("Home, push button") + adapter.events <- xt + expectInterrupt() + waitForGrabs([]bool{false}) + + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "s") + time.Sleep(20 * time.Millisecond) + select { + case got := <-speech.spoken: + t.Fatalf("XTerm terminal event was presented as %q", got) + default: + } + + adapter.events <- atspi.NewEvent("window:create", notification) + expectSpeech("Notification Notification ready") + if !gate.Suspended() || !equalBools(commands.grabs(), []bool{false}) { + t.Fatal("notification restored XTerm keyboard handling") + } + if entries := history.Entries(); len(entries) != 1 || entries[0].Message != "Notification Notification ready" { + t.Fatalf("notification history = %+v", entries) + } + + adapter.events <- atspi.NewEvent(atspi.FocusEvent, browser) + expectSpeech("Browser, push button") + waitForGrabs([]bool{false, true}) + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func TestXTermFocusHonorsKnownApplication(t *testing.T) { + other := atspi.NewEvent(atspi.FocusedStateEvent, atspi.Snapshot{Role: "terminal", ApplicationName: "TestTerminal"}) + other.Detail1 = 1 + if isXTermFocus(other) { + t.Fatal("known non-XTerm terminal was classified as XTerm") + } + xterm := other + xterm.Source.ApplicationName = "xterm" + if !isXTermFocus(xterm) { + t.Fatal("XTerm application focus was not classified as XTerm") + } +} + +func TestXTermFocusDiscardsPendingSpeech(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} + gate := newFocusGate(nil) + gate.speech = speech + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan struct{}) + go func() { + defer close(completed) + processEventsWithInput( + ctx, + slog.New(slog.DiscardHandler), + speech, + adapter, + nil, + nil, + time.Hour, + nil, + nil, + gate, + nil, + ) + }() + + entry := atspi.NewSnapshot("/entry", "Entry", "text", "", "Pending") + entry.Editable = true + terminal := atspi.NewSnapshot("/terminal", "XTerm", "terminal", "", "Prompt") + terminal.ApplicationName = "xterm" + + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + xt := atspi.NewEvent(atspi.FocusedStateEvent, terminal) + xt.Detail1 = 1 + adapter.events <- xt + + select { + case <-speech.interrupt: + case <-time.After(time.Second): + t.Fatal("XTerm focus did not interrupt pending speech") + } + time.Sleep(30 * time.Millisecond) + select { + case got := <-speech.spoken: + t.Fatalf("pending event was presented as %q", got) + default: + } + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("event loop did not stop") + } +} + +func equalBools(left, right []bool) bool { + if len(left) != len(right) { + return false + } + for index, value := range left { + if value != right[index] { + return false + } + } + return true +} + +// Exercise the actual XRecord consumer, not only direct gate calls. Closing the +// input stream provides a deterministic barrier for all queued modifier events. +func TestDelegatedInputSuppressesInterruptAndEcho(t *testing.T) { + for _, suspended := range []bool{true, false} { + t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) { + speech := &fakeSpeechQueue{spoken: make(chan string, 16), interrupt: make(chan struct{}, 16)} + gate := newFocusGate(nil) + gate.SetSuspended(true) + if !suspended { + gate.SetSuspended(false) + } + listener := &fakeInputListener{events: make(chan input.Event, 4), modifierKeycodes: input.NewModifierKeycodes(37, 50)} + for _, code := range []uint8{37, 50} { + listener.events <- input.Event{Keycode: code, Pressed: true} + listener.events <- input.Event{Keycode: code, Pressed: false} + } + close(listener.events) + processInputEvents(context.Background(), slog.New(slog.DiscardHandler), gatedSpeech{speech, gate}, listener, input.EchoSettings{}, nil, nil) + want := 2 + if suspended { + want = 0 + } + if len(speech.interrupt) != want { + t.Fatalf("interrupts = %d, want %d", len(speech.interrupt), want) + } + gatedSpeech{speech, gate}.Enqueue(speechRequest{Text: "key echo"}) + if suspended && len(speech.spoken) != 0 { + t.Fatal("delegated echo reached speech") + } + }) + } +} diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go new file mode 100644 index 0000000..431833c --- /dev/null +++ b/internal/atspi/adapter.go @@ -0,0 +1,408 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +package atspi + +import ( + "context" + "errors" + "fmt" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "runtime/cgo" + "sync" + "sync/atomic" + "unsafe" +) + +/* +#cgo pkg-config: atspi-2 gobject-2.0 +#include "adapter_native.h" +*/ +import "C" + +const eventQueueCapacity = 64 + +const ( + FocusEvent = "focus:" + FocusedStateEvent = "object:state-changed:focused" + WindowActivatedEvent = "window:activate" + WindowDeactivatedEvent = "window:deactivate" + WindowDestroyedEvent = "window:destroy" + WindowCreateEvent = "window:create" + CaretEvent = "object:text-caret-moved" + TextSelectionChangedEvent = "object:text-selection-changed" + TextInsertedEvent = "object:text-changed:insert" + TextDeletedEvent = "object:text-changed:delete" + ValueChangedEvent = "object:value-changed" + AccessibleValueEvent = "object:property-change:accessible-value" + AccessibleNameEvent = "object:property-change:accessible-name" + ActiveDescendantEvent = "object:active-descendant-changed" + SelectionChangedEvent = "object:selection-changed" + SelectedStateEvent = "object:state-changed:selected" + ChildrenChangedEvent = "object:children-changed" + ChildrenAddedEvent = "object:children-changed:add" + ChildrenRemovedEvent = "object:children-changed:remove" + DefunctStateEvent = "object:state-changed:defunct" + BoundsChangedEvent = "object:bounds-changed" + VisibleStateEvent = "object:state-changed:visible" + ShowingStateEvent = "object:state-changed:showing" + TextBoundsChangedEvent = "object:text-bounds-changed" + TextAttributesChangedEvent = "object:text-attributes-changed" + DocumentReloadEvent = "document:reload" + DocumentLoadCompleteEvent = "document:load-complete" + DocumentLoadStoppedEvent = "document:load-stopped" + BusyStateEvent = "object:state-changed:busy" +) + +var eventTypes = []string{ + FocusEvent, + FocusedStateEvent, + WindowActivatedEvent, + WindowDeactivatedEvent, + WindowDestroyedEvent, + WindowCreateEvent, + CaretEvent, + TextSelectionChangedEvent, + TextInsertedEvent, + TextDeletedEvent, + ValueChangedEvent, + AccessibleValueEvent, + AccessibleNameEvent, + ActiveDescendantEvent, + SelectionChangedEvent, + ChildrenChangedEvent, + DefunctStateEvent, + BoundsChangedEvent, + VisibleStateEvent, + ShowingStateEvent, + TextBoundsChangedEvent, + TextAttributesChangedEvent, + DocumentReloadEvent, + DocumentLoadCompleteEvent, + DocumentLoadStoppedEvent, + BusyStateEvent, + "object:state-changed:checked", + "object:state-changed:expanded", + "object:state-changed:indeterminate", + "object:state-changed:invalid-entry", + "object:state-changed:pressed", + "object:state-changed:read-only", + "object:state-changed:required", + SelectedStateEvent, + "object:state-changed:sensitive", + "object:state-changed:visited", +} + +type Adapter struct { + applicationProcesses map[string]string // GLib owner; bounded provider identity cache + webNotificationRoots map[ObjectID]bool // copied identities only; no native references + foregroundReader *xorg.ForegroundReader // GLib thread only + foreground atomic.Pointer[ForegroundState] + foregroundChanges chan struct{} + foregroundTitle atomic.Pointer[ForegroundTitle] + titleChanges chan struct{} + foregroundWindow ObjectID // GLib thread: correlated accessible for current X client + promptRefreshWindow ObjectID // GLib thread: frame activated before its prompt label appeared + promptScannedWindow ObjectID // GLib thread: avoid rescanning repeated activations + promptScannedEpoch uint64 + foregroundRecovery int // GLib thread: bounded retries while a new client publishes AT-SPI + recoveringForeground bool + + events chan Event + treeRequests chan treeCaptureRequest + overflow atomic.Uint64 + treeInvalidations atomic.Uint64 + secureMutex sync.Mutex + secureContext context.Context + secureCancel context.CancelFunc + secureEpoch atomic.Uint64 + secure atomic.Bool + secureReset atomic.Bool + secureResync atomic.Bool + captureSound atomic.Bool + captureTextAttributes atomic.Bool + running atomic.Bool + ready atomic.Bool + loopDone chan struct{} + stopMutex sync.Mutex + treeMutex sync.Mutex + tree *AccessibleTree + documentTree *AccessibleTree + documentTextTree *AccessibleTree + nativeDocument map[ObjectID]*C.AtspiAccessible + documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects + nativeReview map[ObjectID]*C.AtspiAccessible + nativeReviewBus string + navigationEcho map[ObjectID]uint8 + navigationCaretOffset int + nativeFocus []nativeFocusReference // owned only by the GLib thread + mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown + mouseApplicationPID int + documentWindows map[ObjectID]nativeDocumentWindow + documentWindowIDs []ObjectID + selectedTabWindow ObjectID + activeDocument ObjectID + callbackHandle cgo.Handle + callbackData *C.cthulhu_callback_data +} + +type nativeFocusReference struct { + id ObjectID + source *C.AtspiAccessible +} + +type nativeDocumentWindow struct { + id ObjectID + source *C.AtspiAccessible +} + +type treeCaptureResult struct { + item TransferItem + actions ActionSet + point PointReview + ocr OCRGeometry + attributes CharacterAttributes + content DocumentContent + tree *AccessibleTree + owner DocumentContext + caret DocumentCaretText + err error +} + +type treeCaptureRequest struct { + transferItem bool + listActions bool + actionTarget *ActionTarget + actionName string + pointReview bool + pointX, pointY int + pointPID int + ocrGeometry bool + ocrZone *Rectangle + readFocusedText bool + readAttributes bool + setFocusedCaret bool + brailleRoute *BrailleRoute + ctx context.Context + reviewFocus ObjectID + document bool + documentText bool + contextOnly bool + position ObjectReference + caretObject ObjectReference + caretOffset int + caretUnit DocumentCaretUnit + positionOffset int + usePositionOffset bool + activate bool + result chan treeCaptureResult +} + +// PointReview is copied metadata for the accessible at a screen point. +// Text ranges are optional and never include password contents. +type PointReview struct { + Snapshot Snapshot + Window ObjectID + Word, Line string + LineStart int + Offset int // native text offset at the pointer, or -1 +} + +func (adapter *Adapter) ReviewAtPoint(ctx context.Context, x, y, pid int) (PointReview, error) { + if ctx == nil { + return PointReview{}, errors.New("review pointer: nil context") + } + ctx, cancel := adapter.secureRequestContext(ctx) + defer cancel() + adapter.stopMutex.Lock() + defer adapter.stopMutex.Unlock() + if !adapter.ready.Load() || adapter.callbackData == nil { + return PointReview{}, errors.New("review pointer: adapter is not running") + } + request := treeCaptureRequest{ctx: ctx, pointReview: true, pointX: x, pointY: y, pointPID: pid, result: make(chan treeCaptureResult, 1)} + select { + case adapter.treeRequests <- request: + case <-ctx.Done(): + return PointReview{}, ctx.Err() + } + if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 { + <-adapter.treeRequests + return PointReview{}, errors.New("schedule pointer review") + } + select { + case result := <-request.result: + return result.point, result.err + case <-ctx.Done(): + return PointReview{}, ctx.Err() + } +} + +func New() *Adapter { + adapter := &Adapter{ + events: make(chan Event, eventQueueCapacity), + foregroundChanges: make(chan struct{}, 1), + titleChanges: make(chan struct{}, 1), + treeRequests: make(chan treeCaptureRequest, 1), + } + // Direct adapter users historically receive geometry. The application + // narrows this before Start when object-sound spatialization is disabled. + adapter.captureSound.Store(true) + return adapter +} + +// SetCaptureSoundGeometry controls synchronous component and ancestor queries +// performed while copying event snapshots. It is safe to change while the +// adapter is running; the next callback observes the new setting. +func (adapter *Adapter) SetCaptureSoundGeometry(enabled bool) { + adapter.captureSound.Store(enabled) +} + +// SetCaptureTextAttributes enables bounded native run capture for braille. +// Disabled by default to avoid extra provider queries on ordinary focus events. +func (adapter *Adapter) SetCaptureTextAttributes(enabled bool) { + adapter.captureTextAttributes.Store(enabled) +} + +func (adapter *Adapter) Events() <-chan Event { + return adapter.events +} + +func (adapter *Adapter) Overflow() uint64 { + return adapter.overflow.Load() +} + +func (adapter *Adapter) TreeInvalidations() uint64 { + return adapter.treeInvalidations.Load() +} + +// CaptureDesktop copies the current AT-SPI desktop into the adapter's owned +// tree on the native event-loop thread. Calls are serialized with shutdown so +// callback data cannot be released while a capture is pending. +func (adapter *Adapter) CaptureDesktop(ctx context.Context) (*AccessibleTree, error) { + return adapter.capture(ctx, ObjectID{}, "desktop", false, false) +} + +// CaptureReviewWindow copies the focused application's tree into the owned +// model. Flat review needs only the focused frame or dialog, so excluding +// unrelated applications keeps capture bounded on busy desktops. +func (adapter *Adapter) CaptureReviewWindow(ctx context.Context, focus ObjectID) (*AccessibleTree, error) { + if !focus.Valid() { + return nil, errors.New("capture AT-SPI review window: invalid focus") + } + return adapter.capture(ctx, focus, "review window", false, false) +} + +// CaptureDocument captures semantics without flat review's viewport text work. +// Its independent tree cannot replace text required by an active flat cursor. +func (adapter *Adapter) CaptureDocument(ctx context.Context, focus ObjectID) (*AccessibleTree, error) { + if !focus.Valid() { + return nil, errors.New("capture AT-SPI document: invalid focus") + } + return adapter.capture(ctx, focus, "document", true, false) +} + +// CaptureDocumentText also captures visible text and range geometry. Line +// assembly needs those native details; structural navigation does not. +func (adapter *Adapter) CaptureDocumentText(ctx context.Context, focus ObjectID) (*AccessibleTree, error) { + if !focus.Valid() { + return nil, errors.New("capture AT-SPI document text: invalid focus") + } + return adapter.capture(ctx, focus, "document text", true, true) +} + +func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captureName string, document, documentText bool) (*AccessibleTree, error) { + if ctx == nil { + return nil, fmt.Errorf("capture AT-SPI %s: nil context", captureName) + } + ctx, cancel := adapter.secureRequestContext(ctx) + defer cancel() + if err := ctx.Err(); err != nil { + return nil, err + } + + adapter.stopMutex.Lock() + defer adapter.stopMutex.Unlock() + if !adapter.ready.Load() || adapter.callbackData == nil { + return nil, fmt.Errorf("capture AT-SPI %s: adapter is not running", captureName) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + request := treeCaptureRequest{ + ctx: ctx, + reviewFocus: reviewFocus, + document: document, + documentText: documentText, + result: make(chan treeCaptureResult, 1), + } + select { + case adapter.treeRequests <- request: + case <-ctx.Done(): + return nil, ctx.Err() + } + if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 { + <-adapter.treeRequests + return nil, fmt.Errorf("schedule AT-SPI %s capture", captureName) + } + select { + case result := <-request.result: + return result.tree, result.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (adapter *Adapter) installTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) { + return adapter.installCapturedTree(root, nodes, false, false) +} + +func (adapter *Adapter) installCapturedTree(root ObjectID, nodes []TreeNode, document, documentText bool) (*AccessibleTree, error) { + adapter.treeMutex.Lock() + defer adapter.treeMutex.Unlock() + owned := &adapter.tree + if documentText { + owned = &adapter.documentTextTree + } else if document { + owned = &adapter.documentTree + } + if *owned == nil { + tree, err := NewAccessibleTree(root, nodes) + if err != nil { + return nil, err + } + *owned = tree + return tree, nil + } + if err := (*owned).Replace(root, nodes); err != nil { + return nil, err + } + return *owned, nil +} + +func (adapter *Adapter) invalidateTree() { + adapter.treeMutex.Lock() + defer adapter.treeMutex.Unlock() + for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} { + if tree == nil { + continue + } + root, err := tree.Root() + if err == nil { + adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID()))) + } + } +} diff --git a/internal/atspi/adapter_actions_native.go b/internal/atspi/adapter_actions_native.go new file mode 100644 index 0000000..adff6f9 --- /dev/null +++ b/internal/atspi/adapter_actions_native.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "errors" +) + +type AccessibleAction struct{ Name, Label string } +type ActionTarget struct { + Object, Window, Focus ObjectID + Epoch uint64 + Role string +} +type ActionSet struct { + Target ActionTarget + Actions []AccessibleAction +} + +func (adapter *Adapter) AccessibleActions(ctx context.Context, object ObjectID) (ActionSet, error) { + result, err := adapter.documentOperation(ctx, treeCaptureRequest{listActions: true, reviewFocus: object}) + return result.actions, err +} +func (adapter *Adapter) PerformAccessibleAction(ctx context.Context, target ActionTarget, name string) error { + _, err := adapter.documentOperation(ctx, treeCaptureRequest{actionTarget: &target, actionName: name}) + return err +} + +// Native action work stays on the GLib owner, using retained review/document +// handles as well as focus. Never redirect a stale action to a new focus. +func (adapter *Adapter) native_action_source(object ObjectID) (*C.AtspiAccessible, ActionTarget, error) { + var source *C.AtspiAccessible + for _, item := range adapter.nativeFocus { + if item.id == object { + source = item.source + break + } + } + if source == nil { + source = adapter.nativeReview[object] + } + if source == nil { + source = adapter.nativeDocument[object] + } + if source == nil { + return nil, ActionTarget{}, ErrObjectNotFound + } + C.atspi_accessible_clear_cache_single(source) + states := C.atspi_accessible_get_state_set(source) + if states == nil { + return nil, ActionTarget{}, ErrStaleObject + } + defunct := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0 + C.g_object_unref(C.gpointer(states)) + if defunct { + return nil, ActionTarget{}, ErrStaleObject + } + window := C.cthulhu_window_ancestor(source) + if window == nil { + return nil, ActionTarget{}, ErrStaleObject + } + defer C.g_object_unref(C.gpointer(window)) + adapter.refreshForeground() + windowID := nativeObjectID(window) + if !adapter.matchesForeground(window, windowID) { + return nil, ActionTarget{}, ErrStaleObject + } + role := C.atspi_accessible_get_role_name(source, nil) + defer C.g_free(C.gpointer(role)) + target := ActionTarget{Object: object, Window: windowID, Epoch: adapter.Foreground().Epoch, Role: C.GoString(role)} + if len(adapter.nativeFocus) != 0 { + target.Focus = adapter.nativeFocus[len(adapter.nativeFocus)-1].id + } + return source, target, nil +} + +func (adapter *Adapter) native_actions(ctx context.Context, object ObjectID, expected *ActionTarget, name string) (ActionSet, error) { + if err := ctx.Err(); err != nil { + return ActionSet{}, err + } + source, target, err := adapter.native_action_source(object) + if err != nil { + return ActionSet{}, err + } + if expected != nil && target != *expected { + return ActionSet{}, ErrStaleObject + } + action := C.atspi_accessible_get_action_iface(source) + if action == nil { + return ActionSet{Target: target}, nil + } + defer C.g_object_unref(C.gpointer(action)) + var nativeErr *C.GError + count := int(C.atspi_action_get_n_actions(action, &nativeErr)) + if nativeErr != nil { + C.g_error_free(nativeErr) + return ActionSet{}, errors.New("could not read accessible actions") + } + if count < 0 || count > 256 { + return ActionSet{}, errors.New("invalid accessible action count") + } + result := ActionSet{Target: target} + seen := map[string]bool{} + for index := 0; index < count; index++ { + if err := ctx.Err(); err != nil { + return ActionSet{}, err + } + rawName := C.atspi_action_get_name(action, C.gint(index), nil) + actionName := sanitizeSnapshotField(C.GoString(rawName)) + C.g_free(C.gpointer(rawName)) + if actionName == "" || seen[actionName] { + continue + } + seen[actionName] = true + if expected != nil && actionName == name { + if adapter.secure.Load() || ctx.Err() != nil { + return ActionSet{}, ErrStaleObject + } + // Recheck ownership immediately before the external side effect. + _, current, checkErr := adapter.native_action_source(object) + if checkErr != nil || current != target { + return ActionSet{}, ErrStaleObject + } + if C.atspi_action_do_action(action, C.gint(index), &nativeErr) == 0 { + if nativeErr != nil { + C.g_error_free(nativeErr) + } + return ActionSet{}, errors.New("accessible action failed") + } + if nativeErr != nil { + C.g_error_free(nativeErr) + return ActionSet{}, errors.New("accessible action failed") + } + return result, nil + } + labelRaw := C.atspi_action_get_localized_name(action, C.gint(index), nil) + label := sanitizeSnapshotField(C.GoString(labelRaw)) + C.g_free(C.gpointer(labelRaw)) + if label == "" { + description := C.atspi_action_get_description(action, C.gint(index), nil) + label = sanitizeSnapshotField(C.GoString(description)) + C.g_free(C.gpointer(description)) + } + if label == "" { + label = actionName + } + result.Actions = append(result.Actions, AccessibleAction{Name: actionName, Label: label}) + } + if expected != nil { + return ActionSet{}, ErrStaleObject + } + return result, nil +} diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c new file mode 100644 index 0000000..4bc847b --- /dev/null +++ b/internal/atspi/adapter_capture.c @@ -0,0 +1,903 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "adapter_native.h" + +static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret, gint *startOffset); +static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot); +static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining); + +// Collect only the selected row. Walking an entire table on every arrow key +// would block input in large application tables. +gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, + gchar **tableName, gchar **tablePath, + gint *rowIndex, gint *columnIndex, gboolean *guiTable) { + AtspiAccessible *tableAccessible = NULL; + gint row = -1; + gint column = -1; + if (atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_TABLE) { + 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); + if (cell != NULL) { + tableAccessible = atspi_table_cell_get_table(cell, NULL); + atspi_table_cell_get_position(cell, &row, &column, NULL); + g_object_unref(cell); + } + if (tableAccessible == NULL) { + AtspiAccessible *parent = atspi_accessible_get_parent(source, NULL); + if (parent != NULL && atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_TABLE) { + tableAccessible = parent; + gint index = atspi_accessible_get_index_in_parent(source, NULL); + AtspiTable *parentTable = atspi_accessible_get_table_iface(parent); + if (parentTable != NULL) { + row = atspi_table_get_row_at_index(parentTable, index, NULL); + column = atspi_table_get_column_at_index(parentTable, index, NULL); + g_object_unref(parentTable); + } + } else if (parent != NULL) { + g_object_unref(parent); + } + } + } + if (tableAccessible == NULL) return NULL; + *guiTable = TRUE; + AtspiAccessible *ancestor = g_object_ref(tableAccessible); + for (gint depth = 0; depth < 16 && ancestor != NULL; depth++) { + AtspiRole role = atspi_accessible_get_role(ancestor, NULL); + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME || + role == ATSPI_ROLE_DOCUMENT_TEXT || role == ATSPI_ROLE_DOCUMENT_SPREADSHEET) { + *guiTable = FALSE; + g_object_unref(ancestor); + ancestor = NULL; + break; + } + AtspiAccessible *parent = atspi_accessible_get_parent(ancestor, NULL); + g_object_unref(ancestor); + ancestor = parent; + } + if (ancestor != NULL) g_object_unref(ancestor); + AtspiTable *table = atspi_accessible_get_table_iface(tableAccessible); + if (table == NULL) { + g_object_unref(tableAccessible); + return NULL; + } + *rows = atspi_table_get_n_rows(table, NULL); + *columns = atspi_table_get_n_columns(table, NULL); + *tableName = atspi_accessible_get_name(tableAccessible, NULL); + *tablePath = g_strdup(cthulhu_accessible_path(tableAccessible)); + *rowIndex = row; + *columnIndex = column; + GString *result = g_string_new(NULL); + if (row >= 0 && row < *rows && *columns > 0 && *columns <= 32) { + guint remaining = 32; + for (gint column = 0; column < *columns; column++) { + AtspiAccessible *item = atspi_table_get_accessible_at(table, row, column, NULL); + if (item == NULL) 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) { + g_free(name); + name = cthulhu_table_cell_label(item, 0, &remaining); + } + if (name != NULL && *name != '\0' && result->len + strlen(name) < 4096) { + if (result->len > 0) g_string_append(result, ", "); + g_string_append(result, name); + } + g_free(name); + g_object_unref(item); + } + } + g_object_unref(table); + g_object_unref(tableAccessible); + return g_string_free(result, FALSE); +} + +static gboolean cthulhu_capture_sound_rect(AtspiAccessible *source, gint *x, gint *y, + gint *width, gint *height) { + AtspiStateSet *states = atspi_accessible_get_state_set(source); + if (states == NULL) return FALSE; + gboolean available = atspi_state_set_contains(states, ATSPI_STATE_VISIBLE) && + atspi_state_set_contains(states, ATSPI_STATE_SHOWING); + g_object_unref(states); + if (!available) return FALSE; + AtspiComponent *component = atspi_accessible_get_component_iface(source); + if (component == NULL) return FALSE; + GError *error = NULL; + AtspiRect *rect = atspi_component_get_extents(component, ATSPI_COORD_TYPE_SCREEN, &error); + g_object_unref(component); + if (error != NULL) { + g_clear_error(&error); + if (rect != NULL) g_free(rect); + return FALSE; + } + if (rect == NULL || rect->width <= 0 || rect->height <= 0 || + rect->x == G_MININT || rect->y == G_MININT) { + if (rect != NULL) g_free(rect); + return FALSE; + } + *x = rect->x; + *y = rect->y; + *width = rect->width; + *height = rect->height; + g_free(rect); + return TRUE; +} + +static void cthulhu_capture_sound_geometry(AtspiAccessible *source, cthulhu_snapshot *snapshot) { + snapshot->objectGeometryKnown = cthulhu_capture_sound_rect( + source, &snapshot->objectX, &snapshot->objectY, + &snapshot->objectWidth, &snapshot->objectHeight); + if (!snapshot->objectGeometryKnown) return; + + AtspiAccessible *current = g_object_ref(source); + 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); + return; + } + if (role == ATSPI_ROLE_DIALOG || role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) { + snapshot->referenceGeometryKnown = cthulhu_capture_sound_rect( + current, &snapshot->referenceX, &snapshot->referenceY, + &snapshot->referenceWidth, &snapshot->referenceHeight); + g_object_unref(current); + return; + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, &error); + g_object_unref(current); + if (error != NULL) { + g_clear_error(&error); + if (parent != NULL) g_object_unref(parent); + return; + } + current = parent; + } + if (current != NULL) g_object_unref(current); +} + +static void cthulhu_capture_value(AtspiAccessible *source, cthulhu_snapshot *snapshot) { + GError *error = NULL; + AtspiRole role = atspi_accessible_get_role(source, &error); + if (error != NULL) { + g_error_free(error); + return; + } + switch (role) { + case ATSPI_ROLE_COMBO_BOX: { + // Many applications expose the selected item only through Selection. + // Prefer an already supplied description so well-behaved controls do + // not pay another synchronous AT-SPI round trip on every focus event. + if (snapshot->description != NULL && *snapshot->description != '\0') { + return; + } + AtspiSelection *selection = atspi_accessible_get_selection_iface(source); + if (selection == NULL) return; + AtspiAccessible *selected = atspi_selection_get_selected_child(selection, 0, &error); + g_object_unref(selection); + if (error != NULL) { + g_clear_error(&error); + if (selected != NULL) g_object_unref(selected); + return; + } + if (selected == NULL) return; + snapshot->valueText = atspi_accessible_get_name(selected, &error); + g_object_unref(selected); + if (error != NULL) { + g_clear_error(&error); + snapshot->valueText = NULL; + } + return; + } + case ATSPI_ROLE_DIAL: + case ATSPI_ROLE_PROGRESS_BAR: + case ATSPI_ROLE_SCROLL_BAR: + case ATSPI_ROLE_SLIDER: + break; + case ATSPI_ROLE_SPIN_BUTTON: + if (snapshot->text != NULL && *snapshot->text != '\0') { + return; + } + break; + case ATSPI_ROLE_SPLIT_PANE: + break; + default: + return; + } + + AtspiValue *value = atspi_accessible_get_value_iface(source); + if (value == NULL) { + return; + } + + error = NULL; + snapshot->valueText = atspi_value_get_text(value, &error); + if (error != NULL) { + g_error_free(error); + error = NULL; + } + + snapshot->currentValue = atspi_value_get_current_value(value, &error); + if (error != NULL) { + g_error_free(error); + error = NULL; + g_object_unref(value); + return; + } + snapshot->minimumValue = atspi_value_get_minimum_value(value, &error); + if (error != NULL) { + g_error_free(error); + error = NULL; + g_object_unref(value); + return; + } + snapshot->maximumValue = atspi_value_get_maximum_value(value, &error); + if (error == NULL) { + snapshot->valueKnown = TRUE; + } else { + g_clear_error(&error); + } + snapshot->minimumIncrement = atspi_value_get_minimum_increment(value, &error); + if (error != NULL) { + g_clear_error(&error); + snapshot->minimumIncrement = (snapshot->maximumValue - snapshot->minimumValue) / 100.0; + } + g_object_unref(value); +} + +// GTK tree rows can be unnamed while their renderer cells hold the visible +// label (including file names in Thunar's details view). Bound the lookup so +// a large or broken table cannot stall the event callback. +static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining) { + if (source == NULL || *remaining == 0) return NULL; + (*remaining)--; + if (depth != 0) { + GError *error = NULL; + gchar *name = atspi_accessible_get_name(source, &error); + if (error != NULL) g_clear_error(&error); + if (name != NULL && *g_strstrip(name) != '\0') return name; + g_free(name); + } + if (depth >= 2) return NULL; + GError *error = NULL; + gint count = atspi_accessible_get_child_count(source, &error); + if (error != NULL) { + g_clear_error(&error); + return NULL; + } + for (gint index = 0; index < count && index < 12 && *remaining != 0; index++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, &error); + if (error != NULL) { + g_clear_error(&error); + continue; + } + if (child == NULL) continue; + gchar *name = cthulhu_table_cell_label(child, depth + 1, remaining); + g_object_unref(child); + if (name != NULL) return name; + } + return NULL; +} + +// Document cells can contain several text/link children. Collect their labels +// within a fixed budget, rather than the GUI row helper's first-label shortcut. +static gchar *cthulhu_document_cell_label(AtspiAccessible *source, guint depth, guint *remaining) { + if (!source || !*remaining || depth > 8) return NULL; + (*remaining)--; + gchar *name = atspi_accessible_get_name(source, NULL); + if (name && *g_strstrip(name)) return name; + g_free(name); + GString *text = g_string_new(NULL); + gint count = atspi_accessible_get_child_count(source, NULL); + for (gint i = 0; i < count && *remaining; i++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(source, i, NULL); + if (!child) continue; + gchar *label = cthulhu_document_cell_label(child, depth + 1, remaining); + g_object_unref(child); + if (label && *label) { + if (text->len) g_string_append_c(text, ' '); + g_string_append(text, label); + } + g_free(label); + } + return g_string_free(text, FALSE); +} + +// Header arrays own their accessible references. Copy labels before release. +static gchar *cthulhu_table_headers(GPtrArray *headers) { + if (!headers) return NULL; + GString *text = g_string_new(NULL); + guint remaining = 32; + for (guint i = 0; i < headers->len && remaining; i++) { + gchar *label = cthulhu_document_cell_label(g_ptr_array_index(headers, i), 0, &remaining); + if (label && *label) { + if (text->len) g_string_append(text, ", "); + g_string_append(text, label); + } + g_free(label); + } + g_ptr_array_unref(headers); + return g_string_free(text, FALSE); +} + +cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText, + gboolean captureCaretCharacter, + gboolean captureDocumentMetadata, + gboolean captureSoundGeometry, + gboolean captureApplicationName) { + cthulhu_snapshot snapshot = {0}; + if (source == NULL) { + return snapshot; + } + snapshot.path = ATSPI_OBJECT(source)->path; + if (ATSPI_OBJECT(source)->app != NULL) { + snapshot.busName = ATSPI_OBJECT(source)->app->bus_name; + if (captureApplicationName) { + GError *applicationError = NULL; + AtspiAccessible *application = atspi_accessible_get_application(source, &applicationError); + if (applicationError != NULL) { + g_clear_error(&applicationError); + } + if (application != NULL) { + snapshot.applicationName = atspi_accessible_get_name(application, &applicationError); + if (applicationError != NULL) { + g_clear_error(&applicationError); + } + g_object_unref(application); + } + } + } + AtspiStateSet *states = atspi_accessible_get_state_set(source); + if (states != NULL) { + snapshot.statesKnown = TRUE; + snapshot.visible = atspi_state_set_contains(states, ATSPI_STATE_VISIBLE); + snapshot.showing = atspi_state_set_contains(states, ATSPI_STATE_SHOWING); + snapshot.active = atspi_state_set_contains(states, ATSPI_STATE_ACTIVE); + snapshot.autocomplete = atspi_state_set_contains(states, ATSPI_STATE_SUPPORTS_AUTOCOMPLETION) && atspi_state_set_contains(states, ATSPI_STATE_SINGLE_LINE); + snapshot.focusable = atspi_state_set_contains(states, ATSPI_STATE_FOCUSABLE); + snapshot.focused = atspi_state_set_contains(states, ATSPI_STATE_FOCUSED); + snapshot.hasPopup = atspi_state_set_contains(states, ATSPI_STATE_HAS_POPUP); + snapshot.sensitiveKnown = TRUE; + snapshot.checked = atspi_state_set_contains(states, ATSPI_STATE_CHECKED) || atspi_state_set_contains(states, ATSPI_STATE_PRESSED); + snapshot.defunct = atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); + snapshot.editable = atspi_state_set_contains(states, ATSPI_STATE_EDITABLE); + snapshot.expandable = atspi_state_set_contains(states, ATSPI_STATE_EXPANDABLE) || atspi_state_set_contains(states, ATSPI_STATE_EXPANDED); + snapshot.expanded = atspi_state_set_contains(states, ATSPI_STATE_EXPANDED); + snapshot.indeterminate = atspi_state_set_contains(states, ATSPI_STATE_INDETERMINATE); + snapshot.invalid = atspi_state_set_contains(states, ATSPI_STATE_INVALID_ENTRY) || atspi_state_set_contains(states, ATSPI_STATE_INVALID); + snapshot.multiselectable = atspi_state_set_contains(states, ATSPI_STATE_MULTISELECTABLE); + snapshot.readOnly = atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY); + snapshot.required = atspi_state_set_contains(states, ATSPI_STATE_REQUIRED); + snapshot.selected = atspi_state_set_contains(states, ATSPI_STATE_SELECTED); + snapshot.sensitive = atspi_state_set_contains(states, ATSPI_STATE_SENSITIVE); + snapshot.visited = atspi_state_set_contains(states, ATSPI_STATE_VISITED); + g_object_unref(states); + } + GError *error = NULL; + snapshot.name = atspi_accessible_get_name(source, &error); + if (error != NULL) { + g_error_free(error); + error = NULL; + } + snapshot.role = atspi_accessible_get_role_name(source, &error); + if (error != NULL) { + g_error_free(error); + error = NULL; + } + if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 || + g_strcmp0(snapshot.role, "list box") == 0)) { + snapshot.childCount = atspi_accessible_get_child_count(source, &error); + snapshot.childCountKnown = error == NULL && snapshot.childCount >= 0; + g_clear_error(&error); + } + if (!snapshot.defunct && !captureDocumentMetadata && g_strcmp0(snapshot.role, "table cell") == 0 && + (snapshot.name == NULL || *snapshot.name == '\0')) { + guint remaining = 20; + gchar *label = cthulhu_table_cell_label(source, 0, &remaining); + if (label != NULL) { + g_free(snapshot.name); + snapshot.name = label; + } + } + snapshot.description = atspi_accessible_get_description(source, &error); + if (error != NULL) { + g_error_free(error); + } + if (captureDocumentMetadata) { + cthulhu_capture_document_metrics(source, &snapshot); + } + if (captureSoundGeometry) { + cthulhu_capture_sound_geometry(source, &snapshot); + } + // Copy only semantic attribute fields used by navigation; arbitrary + // browser attributes can include URLs and unrelated document data. + error = NULL; + GHashTable *attributes = NULL; + gboolean captureSetPosition = !snapshot.defunct && cthulhu_has_set_position(snapshot.role); + if (captureDocumentMetadata || g_strcmp0(snapshot.role, "heading") == 0 || captureSetPosition) { + attributes = atspi_accessible_get_attributes(source, &error); + } + if (error != NULL) { + g_clear_error(&error); + } + if (captureSetPosition) cthulhu_capture_set_position(source, attributes, &snapshot); + if (attributes != NULL) { + if (captureDocumentMetadata) { + snapshot.explicitName = g_strcmp0(g_hash_table_lookup(attributes, "explicit-name"), "true") == 0; + const gchar *xmlRoles = g_hash_table_lookup(attributes, "xml-roles"); + if (xmlRoles != NULL) { + snapshot.xmlRoles = g_strdup(xmlRoles); + } + const gchar *htmlTag = g_hash_table_lookup(attributes, "tag"); + if (htmlTag != NULL) snapshot.htmlTag = g_strdup(htmlTag); + const gchar *elementID = g_hash_table_lookup(attributes, "id"); + if (elementID != NULL) { + snapshot.elementID = g_strdup(elementID); + } + const gchar *containerLive = g_hash_table_lookup(attributes, "container-live"); + if (containerLive != NULL && (g_strcmp0(containerLive, "off") == 0 || + g_strcmp0(containerLive, "polite") == 0 || + g_strcmp0(containerLive, "assertive") == 0)) { + snapshot.liveRegion = TRUE; + } + const gchar *layoutGuess = g_hash_table_lookup(attributes, "layout-guess"); + if (g_strcmp0(layoutGuess, "true") == 0) { + snapshot.layoutTable = TRUE; + } + } + if (g_strcmp0(snapshot.role, "heading") == 0) { + const gchar *level = g_hash_table_lookup(attributes, "level"); + if (level != NULL && *level >= '1' && *level <= '9') { + gchar *end = NULL; + gint64 number = g_ascii_strtoll(level, &end, 10); + if (end != NULL && *end == '\0' && number <= G_MAXINT) { + snapshot.headingLevel = (gint)number; + } + } + } + if (attributes != NULL) g_hash_table_unref(attributes); + } + if (captureDocumentMetadata && (g_strcmp0(snapshot.role, "table") == 0 || g_strcmp0(snapshot.role, "tree table") == 0)) { + AtspiTable *table = atspi_accessible_get_table_iface(source); + if (table != NULL) { + error = NULL; + snapshot.tableRowCount = atspi_table_get_n_rows(table, &error); + if (error != NULL) { + g_clear_error(&error); + } + snapshot.tableColumnCount = atspi_table_get_n_columns(table, NULL); + g_object_unref(table); + } + } + if (captureDocumentMetadata && (g_strcmp0(snapshot.role, "table cell") == 0 || + g_strcmp0(snapshot.role, "column header") == 0 || g_strcmp0(snapshot.role, "row header") == 0)) { + if (!snapshot.name || !*snapshot.name) { + guint remaining = 64; + gchar *label = cthulhu_document_cell_label(source, 0, &remaining); + g_free(snapshot.name); + snapshot.name = label; + } + AtspiTableCell *cell = atspi_accessible_get_table_cell(source); + if (cell) { + error = NULL; + snapshot.tableRowIndex = snapshot.tableColumnIndex = -1; + atspi_table_cell_get_row_column_span(cell, &snapshot.tableRowIndex, &snapshot.tableColumnIndex, + &snapshot.tableRowSpan, &snapshot.tableColumnSpan, &error); + snapshot.tablePositionKnown = !error && snapshot.tableRowIndex >= 0 && snapshot.tableColumnIndex >= 0 && + snapshot.tableRowSpan > 0 && snapshot.tableColumnSpan > 0; + g_clear_error(&error); + snapshot.tableRowHeaders = cthulhu_table_headers(atspi_table_cell_get_row_header_cells(cell, NULL)); + snapshot.tableColumnHeaders = cthulhu_table_headers(atspi_table_cell_get_column_header_cells(cell, NULL)); + g_object_unref(cell); + } + } + AtspiAction *action = captureDocumentMetadata ? atspi_accessible_get_action_iface(source) : NULL; + if (action != NULL) { + error = NULL; + gint actionCount = atspi_action_get_n_actions(action, &error); + if (error != NULL) { + g_clear_error(&error); + } + for (gint index = 0; index < actionCount; index++) { + error = NULL; + gchar *actionName = atspi_action_get_name(action, index, &error); + if (error != NULL) { + g_clear_error(&error); + } + if (g_strcmp0(actionName, "click") == 0) { + snapshot.supportsClick = TRUE; + } + g_free(actionName); + } + g_object_unref(action); + } + // Text and caret capture make synchronous AT-SPI calls. Focus events for + // ordinary controls do not use that text in presentation, so avoid stalling + // the GLib event loop for each Tab stop. Caret events still need line and + // character text even when they originate in static web content. + if (captureText && g_strcmp0(snapshot.role, "password text") != 0 && + (snapshot.editable || g_strcmp0(snapshot.role, "terminal") == 0 || captureCaretCharacter)) { + snapshot.text = cthulhu_capture_text(source, &snapshot.textBeforeCaret, &snapshot.textStartOffset); + } + if (captureCaretCharacter && g_strcmp0(snapshot.role, "password text") != 0) { + snapshot.caretCharacter = cthulhu_capture_caret_unit(source, ATSPI_TEXT_GRANULARITY_CHAR); + } + cthulhu_capture_value(source, &snapshot); + return snapshot; +} + +static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret, gint *startOffset) { + *beforeCaret = NULL; + AtspiText *text = atspi_accessible_get_text(source); + if (text == NULL) { + return NULL; + } + GError *error = NULL; + gint caretOffset = atspi_text_get_caret_offset(text, &error); + if (error != NULL) { + g_error_free(error); + g_object_unref(text); + return NULL; + } + AtspiTextRange *range = atspi_text_get_string_at_offset( + text, caretOffset, ATSPI_TEXT_GRANULARITY_LINE, &error); + // AXText.get_line_at_offset retries Chromium's invalid end-of-text + // range. A valid empty range is a real blank line and must stay empty. + if (error == NULL && range != NULL && caretOffset > 0) { + gboolean retry = range->start_offset > caretOffset; + if (range->start_offset == -1 && range->end_offset == -1) { + gint count = atspi_text_get_character_count(text, &error); + retry = error == NULL && caretOffset == count; + } + if (retry) { + g_boxed_free(atspi_text_range_get_type(), range); + range = atspi_text_get_string_at_offset( + text, caretOffset - 1, ATSPI_TEXT_GRANULARITY_LINE, &error); + } + } + g_object_unref(text); + if (error != NULL) { + g_error_free(error); + if (range != NULL) g_boxed_free(atspi_text_range_get_type(), range); + return NULL; + } + if (range == NULL) { + return NULL; + } + gchar *content = g_strdup(range->content != NULL ? range->content : ""); + *startOffset = range->start_offset; + gint relativeCaret = caretOffset - range->start_offset; + glong characterCount = g_utf8_strlen(content, -1); + if (relativeCaret < 0) { + relativeCaret = 0; + } else if (relativeCaret > characterCount) { + relativeCaret = (gint)characterCount; + } + *beforeCaret = g_utf8_substring(content, 0, relativeCaret); + g_free(range->content); + g_free(range); + return content; +} + +static gchar *cthulhu_label_text(AtspiAccessible *source) { + AtspiText *text = atspi_accessible_get_text(source); + if (text != NULL) { + GError *error = NULL; + gint characterCount = atspi_text_get_character_count(text, &error); + if (error != NULL) { + g_clear_error(&error); + g_object_unref(text); + return NULL; + } + gchar *content = atspi_text_get_text(text, 0, characterCount, &error); + g_object_unref(text); + if (error != NULL) { + g_clear_error(&error); + return NULL; + } + return content != NULL ? content : g_strdup(""); + } + + GError *error = NULL; + gchar *name = atspi_accessible_get_name(source, &error); + if (error != NULL) g_error_free(error); + return name != NULL ? name : g_strdup(""); +} + +static void cthulhu_append_label_texts(AtspiAccessible *source, GString *contents, + int *visited) { + if (source == NULL || *visited >= 100 || contents->len >= 4096) return; + (*visited)++; + GError *error = NULL; + AtspiRole role = atspi_accessible_get_role(source, &error); + if (error != NULL) g_clear_error(&error); + if (role == ATSPI_ROLE_LABEL) { + gchar *text = cthulhu_label_text(source); + if (text != NULL && *text != '\0') { + if (contents->len != 0) g_string_append_c(contents, ' '); + g_string_append(contents, text); + } + g_free(text); + } + error = NULL; + gint childCount = atspi_accessible_get_child_count(source, &error); + if (error != NULL) { + g_clear_error(&error); + return; + } + for (gint index = 0; index < childCount && *visited < 100 && contents->len < 4096; index++) { + error = NULL; + AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, &error); + if (error != NULL) { + g_clear_error(&error); + continue; + } + cthulhu_append_label_texts(child, contents, visited); + if (child != NULL) g_object_unref(child); + } +} + +gchar *cthulhu_collect_notification_text(AtspiAccessible *source) { + if (source == NULL) return g_strdup(""); + GString *contents = g_string_new(""); + int visited = 0; + cthulhu_append_label_texts(source, contents, &visited); + return g_string_free(contents, FALSE); +} + +// YAD exposes prompts as labels beside buttons or a choice list. Keep the +// capture within that small shape; ordinary frames contain unrelated labels. +static gboolean cthulhu_append_prompt_labels(AtspiAccessible *source, GString *contents, + gint *visited, gint *buttons, gint *labels, gint *choices) { + if (source == NULL || *visited >= 100 || contents->len >= 4096) return FALSE; + (*visited)++; + GError *error = NULL; + AtspiRole role = atspi_accessible_get_role(source, &error); + if (error != NULL) { + g_clear_error(&error); + return FALSE; + } + if (role == ATSPI_ROLE_BUTTON) { + (*buttons)++; + return *buttons <= 4; + } + if (role == ATSPI_ROLE_LABEL || role == ATSPI_ROLE_STATIC) { + AtspiStateSet *states = atspi_accessible_get_state_set(source); + gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING); + if (states != NULL) g_object_unref(states); + if (!showing) return TRUE; + gchar *label = cthulhu_label_text(source); + if (label != NULL && *label != '\0') { + if (contents->len != 0) g_string_append_c(contents, ' '); + g_string_append(contents, label); + (*labels)++; + } + g_free(label); + return TRUE; + } + if (role == ATSPI_ROLE_ICON || role == ATSPI_ROLE_SCROLL_BAR) return TRUE; + if (role == ATSPI_ROLE_TREE_TABLE || role == ATSPI_ROLE_TREE || + role == ATSPI_ROLE_TABLE || role == ATSPI_ROLE_LIST) { + (*choices)++; + return *choices <= 1; + } + if (role != ATSPI_ROLE_FRAME && role != ATSPI_ROLE_DIALOG && + role != ATSPI_ROLE_FILLER && role != ATSPI_ROLE_PANEL && + role != ATSPI_ROLE_SCROLL_PANE) return FALSE; + gint childCount = atspi_accessible_get_child_count(source, &error); + if (error != NULL) { + g_clear_error(&error); + return FALSE; + } + for (gint index = 0; index < childCount; index++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, &error); + if (error != NULL || child == NULL) { + g_clear_error(&error); + if (child != NULL) g_object_unref(child); + return FALSE; + } + gboolean valid = cthulhu_append_prompt_labels(child, contents, visited, buttons, labels, choices); + g_object_unref(child); + if (!valid) return FALSE; + } + return TRUE; +} + +gchar *cthulhu_collect_simple_prompt_text(AtspiAccessible *source) { + if (source == NULL) return NULL; + GString *contents = g_string_new(""); + gint visited = 0, buttons = 0, labels = 0, choices = 0; + gboolean valid = cthulhu_append_prompt_labels(source, contents, &visited, &buttons, &labels, &choices); + if (!valid || labels == 0 || (buttons == 0 && choices == 0)) { + g_string_free(contents, TRUE); + return NULL; + } + return g_string_free(contents, FALSE); +} + +// Follow the same label/ancestor conventions used by GTK radio groups. A +// named panel or filler is the group's question, not the option's own name. +gchar *cthulhu_radio_group_name(AtspiAccessible *source) { + GArray *relations = atspi_accessible_get_relation_set(source, NULL); + if (relations != NULL) { + for (guint i = 0; i < relations->len; i++) { + AtspiRelation *relation = g_array_index(relations, AtspiRelation *, i); + if (atspi_relation_get_relation_type(relation) == ATSPI_RELATION_LABELLED_BY && + atspi_relation_get_n_targets(relation) > 0) { + AtspiAccessible *label = atspi_relation_get_target(relation, 0); + gchar *name = label != NULL ? cthulhu_label_text(label) : NULL; + gchar *option = atspi_accessible_get_name(source, NULL); + gboolean ownLabel = g_strcmp0(name, option) == 0; + g_free(option); + if (label != NULL) g_object_unref(label); + for (guint j = 0; j < relations->len; j++) + g_object_unref(g_array_index(relations, AtspiRelation *, j)); + g_array_unref(relations); + if (name != NULL && *name != '\0' && !ownLabel) return name; + g_free(name); + relations = NULL; + break; + } + } + if (relations != NULL) { + for (guint i = 0; i < relations->len; i++) + g_object_unref(g_array_index(relations, AtspiRelation *, i)); + g_array_unref(relations); + } + } + AtspiAccessible *current = atspi_accessible_get_parent(source, NULL); + for (int depth = 0; current != NULL && depth < 16; depth++) { + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_DIALOG || role == ATSPI_ROLE_APPLICATION) { + g_object_unref(current); + return NULL; + } + if (role == ATSPI_ROLE_PANEL || role == ATSPI_ROLE_FILLER) { + gchar *name = atspi_accessible_get_name(current, NULL); + if (name != NULL && *name != '\0') { + g_object_unref(current); + return name; + } + g_free(name); + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + return NULL; +} + +static void cthulhu_append_live_text(AtspiAccessible *source, GString *contents, int *visited) { + if (source == NULL || *visited >= 100 || contents->len >= 4096) return; + (*visited)++; + GError *error = NULL; + gint childCount = atspi_accessible_get_child_count(source, &error); + if (error != NULL) { + g_clear_error(&error); + childCount = 0; + } + guint before = contents->len; + for (gint index = 0; index < childCount && *visited < 100 && contents->len < 4096; index++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, &error); + if (error != NULL) g_clear_error(&error); + if (child != NULL) { + cthulhu_append_live_text(child, contents, visited); + g_object_unref(child); + } + } + if (contents->len == before) { + gchar *text = cthulhu_label_text(source); + if (text != NULL && *text != '\0') { + if (contents->len != 0) g_string_append_c(contents, ' '); + g_string_append(contents, text); + } + g_free(text); + } +} + +gchar *cthulhu_live_content(AtspiAccessible *source) { + GString *contents = g_string_new(""); + int visited = 0; + cthulhu_append_live_text(source, contents, &visited); + return g_string_free(contents, FALSE); +} + +gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit) { + AtspiText *text = atspi_accessible_get_text(source); + if (text == NULL) { + return NULL; + } + GError *error = NULL; + gint caretOffset = atspi_text_get_caret_offset(text, &error); + if (error != NULL) { + g_error_free(error); + g_object_unref(text); + return NULL; + } + if (caretOffset < 0) { + g_object_unref(text); + return NULL; + } + AtspiTextRange *range = atspi_text_get_string_at_offset( + text, caretOffset, unit, &error); + g_object_unref(text); + if (error != NULL) { + g_error_free(error); + if (range != NULL) g_boxed_free(atspi_text_range_get_type(), range); + return NULL; + } + if (range == NULL) { + return NULL; + } + gchar *content = g_strdup(range->content != NULL ? range->content : ""); + g_free(range->content); + g_free(range); + return content; +} + +static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot) { + const gchar *role = snapshot->role; + if (g_strcmp0(role, "paragraph") != 0 && + g_strcmp0(role, "list item") != 0 && + g_strcmp0(role, "math") != 0 && + g_strcmp0(role, "static") != 0 && + g_strcmp0(role, "column header") != 0 && + g_strcmp0(role, "row header") != 0 && + g_strcmp0(role, "table cell") != 0 && + g_strcmp0(role, "table row") != 0 && + g_strcmp0(role, "text") != 0 && + g_strcmp0(role, "section") != 0 && + g_strcmp0(role, "article") != 0 && + g_strcmp0(role, "description term") != 0 && + g_strcmp0(role, "description value") != 0 && + g_strcmp0(role, "document email") != 0 && + g_strcmp0(role, "document frame") != 0 && + g_strcmp0(role, "document presentation") != 0 && + g_strcmp0(role, "document spreadsheet") != 0 && + g_strcmp0(role, "document text") != 0 && + g_strcmp0(role, "document web") != 0 && + g_strcmp0(role, "block quote") != 0 && + g_strcmp0(role, "description list") != 0 && + g_strcmp0(role, "form") != 0 && + g_strcmp0(role, "footer") != 0 && + g_strcmp0(role, "header") != 0 && + g_strcmp0(role, "landmark") != 0 && + g_strcmp0(role, "log") != 0 && + g_strcmp0(role, "list") != 0 && + g_strcmp0(role, "marquee") != 0 && + g_strcmp0(role, "panel") != 0 && + g_strcmp0(role, "table") != 0 && + g_strcmp0(role, "tree") != 0 && + g_strcmp0(role, "tree table") != 0) { + return; + } + AtspiText *text = atspi_accessible_get_text(source); + if (text == NULL) { + return; + } + GError *error = NULL; + gint characterCount = atspi_text_get_character_count(text, &error); + if (error != NULL) { + g_clear_error(&error); + return; + } + snapshot->characterCount = characterCount; + if (characterCount > 75) { + error = NULL; + gchar *contents = atspi_text_get_text(text, 0, characterCount, &error); + if (error != NULL) { + g_clear_error(&error); + } else if (contents != NULL) { + for (const gchar *cursor = contents; *cursor != '\0'; cursor = g_utf8_next_char(cursor)) { + if (g_utf8_get_char(cursor) == 0xFFFC) { + snapshot->embeddedObjectCount++; + } + } + g_free(contents); + } + } +} diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go new file mode 100644 index 0000000..491bbe9 --- /dev/null +++ b/internal/atspi/adapter_document_native.go @@ -0,0 +1,508 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + "unsafe" +) + +func (adapter *Adapter) nativeFocusIs(id ObjectID) bool { + return id.Valid() && len(adapter.nativeFocus) != 0 && adapter.nativeFocus[len(adapter.nativeFocus)-1].id == id +} + +// Native document handles are bounded by the capture's node limit. Only the +// GLib thread owns them; recapture and shutdown release every reference. +func (adapter *Adapter) clearNativeDocument() { + for _, source := range adapter.nativeDocument { + C.g_object_unref(C.gpointer(source)) + } + adapter.nativeDocument = nil + clear(adapter.documentLineCache) +} + +// Review handles belong to the GLib thread and are replaced atomically after +// each successful window capture. Keeping them permits dirty-node recapture. +func (adapter *Adapter) clearNativeReview() { + for _, source := range adapter.nativeReview { + C.g_object_unref(C.gpointer(source)) + } + adapter.nativeReview = nil + adapter.nativeReviewBus = "" +} + +func (adapter *Adapter) documentOperation(ctx context.Context, request treeCaptureRequest) (treeCaptureResult, error) { + ctx, cancel := adapter.secureRequestContext(ctx) + defer cancel() + if err := ctx.Err(); err != nil { + return treeCaptureResult{}, err + } + adapter.stopMutex.Lock() + defer adapter.stopMutex.Unlock() + if !adapter.ready.Load() || adapter.callbackData == nil { + return treeCaptureResult{}, errors.New("AT-SPI adapter is not running") + } + request.ctx, request.result = ctx, make(chan treeCaptureResult, 1) + select { + case adapter.treeRequests <- request: + case <-ctx.Done(): + return treeCaptureResult{}, ctx.Err() + } + if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 { + <-adapter.treeRequests + return treeCaptureResult{}, errors.New("schedule document operation") + } + select { + case result := <-request.result: + return result, result.err + case <-ctx.Done(): + return treeCaptureResult{}, ctx.Err() + } +} + +func (adapter *Adapter) CaptureDocumentContext(ctx context.Context, focus ObjectID) (DocumentContext, error) { + result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, contextOnly: true}) + return result.owner, err +} + +func (adapter *Adapter) SetDocumentPosition(ctx context.Context, position ObjectReference, focus ObjectID) error { + if !position.Valid() || !focus.Valid() { + return ErrStaleObject + } + _, err := adapter.documentOperation(ctx, treeCaptureRequest{position: position, reviewFocus: focus}) + return err +} + +// SetDocumentCaretPosition also places the browser caret at the virtual browse +// offset, so native Tab traversal starts from the announced location. +func (adapter *Adapter) SetDocumentCaretPosition(ctx context.Context, position ObjectReference, focus ObjectID, offset int) error { + if !position.Valid() || !focus.Valid() || offset < 0 { + return ErrStaleObject + } + _, err := adapter.documentOperation(ctx, treeCaptureRequest{position: position, reviewFocus: focus, positionOffset: offset, usePositionOffset: true}) + return err +} + +func (adapter *Adapter) ActivateDocumentObject(ctx context.Context, position ObjectReference, focus ObjectID) error { + if !position.Valid() || !focus.Valid() { + return ErrStaleObject + } + _, err := adapter.documentOperation(ctx, treeCaptureRequest{position: position, reviewFocus: focus, activate: true}) + return err +} + +func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, position ObjectReference, focus ObjectID) error { + if err := ctx.Err(); err != nil { + return err + } + adapter.treeMutex.Lock() + tree := adapter.documentTree + adapter.treeMutex.Unlock() + if tree == nil { + return ErrStaleObject + } + snapshot, err := tree.Resolve(position) + if err != nil { + return err + } + if snapshot.Role != "link" && !snapshot.SupportsClick { + return errors.New("current document object is not activatable") + } + if err := documentPositionMatchesFocus(tree, position, focus); err != nil { + return err + } + if len(adapter.nativeFocus) > 0 { + latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id + if latest != focus { + if err := documentPositionMatchesFocus(tree, position, latest); err != nil { + return err + } + } + } + source := adapter.nativeDocument[position.ID()] + if source == nil { + return ErrStaleObject + } + role := C.atspi_accessible_get_role_name(source, nil) + currentRole := sanitizeSnapshotField(C.GoString(role)) + C.g_free(C.gpointer(role)) + if currentRole != snapshot.Role { + return ErrStaleObject + } + window := C.cthulhu_window_ancestor(source) + if window == nil { + return ErrStaleObject + } + foreground := adapter.matchesForeground(window, nativeObjectID(window)) + C.g_object_unref(C.gpointer(window)) + if !foreground { + return ErrStaleObject + } + action := C.atspi_accessible_get_action_iface(source) + if action == nil { + return errors.New("current document object has no accessible action") + } + defer C.g_object_unref(C.gpointer(action)) + names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"} + count := int(C.atspi_action_get_n_actions(action, nil)) + for _, wanted := range names { + for index := 0; index < count; index++ { + name := C.atspi_action_get_name(action, C.gint(index), nil) + matched := strings.EqualFold(C.GoString(name), wanted) + C.g_free(C.gpointer(name)) + if !matched { + continue + } + if err := ctx.Err(); err != nil { + return err + } + var nativeErr *C.GError + activated := C.atspi_action_do_action(action, C.gint(index), &nativeErr) + if nativeErr != nil { + err := nativeError("activate document object", nativeErr) + C.g_error_free(nativeErr) + return err + } + if activated != 0 { + return nil + } + } + } + return errors.New("current document object refused activation") +} + +// ReadDocumentCaret reads one bounded AT-SPI text unit on the GLib owner. +// The worker keeps the virtual offset; no native caret or focus is changed. +func (adapter *Adapter) ReadDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) { + if !position.Valid() || offset < 0 || !unit.Valid() { + return DocumentCaretText{}, ErrStaleObject + } + result, err := adapter.documentOperation(ctx, treeCaptureRequest{caretObject: position, caretOffset: offset, caretUnit: unit}) + return result.caret, err +} + +func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) { + if err := ctx.Err(); err != nil { + return DocumentCaretText{}, err + } + adapter.treeMutex.Lock() + tree := adapter.documentTree + adapter.treeMutex.Unlock() + if tree == nil { + return DocumentCaretText{}, ErrStaleObject + } + snapshot, err := tree.Resolve(position) + if err != nil { + return DocumentCaretText{}, err + } + if unit == DocumentCaretBrailleLine && snapshot.Role == "password text" { + return DocumentCaretText{}, ErrNoDocumentCaret + } + source := adapter.nativeDocument[position.ID()] + if source == nil { + return DocumentCaretText{}, ErrStaleObject + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return DocumentCaretText{}, ErrNoDocumentCaret + } + defer C.g_object_unref(C.gpointer(text)) + var nativeErr *C.GError + count := int(C.atspi_text_get_character_count(text, &nativeErr)) + if nativeErr != nil { + err := nativeError("get document text count", nativeErr) + C.g_error_free(nativeErr) + return DocumentCaretText{}, err + } + if count <= 0 || offset >= count { + if unit != DocumentCaretBrailleLine || count <= 0 || offset != count { + return DocumentCaretText{CharacterCount: count}, nil + } + // A caret after the final character still belongs to the final line. + offset = count - 1 + } + if unit != DocumentCaretCharacter && unit != DocumentCaretWord { + line, err := adapter.readNativeDocumentLine(ctx, position.ID(), snapshot, text, offset, count) + if err != nil { + return DocumentCaretText{}, err + } + line.RawText = line.Text + if unit == DocumentCaretBrailleLine && adapter.captureTextAttributes.Load() { + line.TextAttributeRuns = native_text_attribute_runs(text, line.Start, line.End, TextAttributeRun{}) + } + if unit == DocumentCaretBrailleLine && C.atspi_text_get_n_selections(text, nil) > 0 { + if selection := C.atspi_text_get_selection(text, 0, nil); selection != nil { + line.SelectionStart, line.SelectionEnd = int(selection.start_offset), int(selection.end_offset) + C.g_free(C.gpointer(selection)) + } + } + // Geometry is optional: unavailable bounds keep object-local reading. + // Query on the GLib owner; no native reference leaves this operation. + if line.Start >= 0 && line.End > line.Start && line.End <= count { + line.Bounds, _ = (nativeReviewText{text: text}).bounds(ctx, line.Start, line.End) + if err := ctx.Err(); err != nil { + return DocumentCaretText{}, err + } + } + if unit == DocumentCaretLineRemainder && line.Start < offset && offset < line.End && offset-line.Start <= utf8.RuneCountInString(line.Text) { + line.Text = string([]rune(line.Text)[offset-line.Start:]) + line.Start = offset + } + if unit != DocumentCaretBrailleLine { + line.Misspelled = native_misspelled(text, offset) + line.Text = sanitizeSnapshotField(line.Text) + } + return line, nil + } + granularity := C.AtspiTextGranularity(C.ATSPI_TEXT_GRANULARITY_LINE) + switch unit { + case DocumentCaretCharacter: + granularity = C.AtspiTextGranularity(C.ATSPI_TEXT_GRANULARITY_CHAR) + case DocumentCaretWord: + granularity = C.AtspiTextGranularity(C.ATSPI_TEXT_GRANULARITY_WORD) + } + rangeText := C.atspi_text_get_string_at_offset(text, C.gint(offset), granularity, &nativeErr) + if nativeErr != nil { + err := nativeError("read document text", nativeErr) + C.g_error_free(nativeErr) + return DocumentCaretText{}, err + } + if rangeText == nil { + return DocumentCaretText{CharacterCount: count}, nil + } + defer C.g_free(C.gpointer(rangeText)) + defer C.g_free(C.gpointer(rangeText.content)) + content := sanitizeSnapshotField(C.GoString(rangeText.content)) + if unit == DocumentCaretCharacter { + // Whitespace is a navigable character. Label normalization would + // erase it, preventing space speech and word delimiter detection. + content = documentCaretCharacter(C.GoString(rangeText.content)) + } + return DocumentCaretText{ + Start: int(rangeText.start_offset), End: int(rangeText.end_offset), + Text: content, CharacterCount: count, Misspelled: native_misspelled(text, offset), + }, nil +} + +// Capture only ancestors: changing focus must not require scanning a whole page. +func (adapter *Adapter) nativeDocumentContext(ctx context.Context, focus ObjectID) (DocumentContext, error) { + var source *C.AtspiAccessible + for _, item := range adapter.nativeFocus { + if item.id == focus { + source = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(item.source))) + break + } + } + if source == nil { + return DocumentContext{}, ErrObjectNotFound + } + defer func() { + if source != nil { + C.g_object_unref(C.gpointer(source)) + } + }() + nodes := []TreeNode{} + seen := map[ObjectID]bool{} + var child ObjectID + for source != nil { + if err := ctx.Err(); err != nil { + return DocumentContext{}, err + } + C.atspi_accessible_clear_cache_single(source) + snapshot := snapshotFromNativeWithCaret(source, false, false, false, false) + if len(nodes) >= 256 || seen[snapshot.ID()] || snapshot.Defunct { + return DocumentContext{}, ErrStaleObject + } + seen[snapshot.ID()] = true + node := TreeNode{Snapshot: snapshot} + if child.Valid() { + node.Children = []ObjectID{child} + } + nodes = append(nodes, node) + child = snapshot.ID() + if snapshot.Role == "application" { + break + } + var nativeErr *C.GError + parent := C.atspi_accessible_get_parent(source, &nativeErr) + if nativeErr != nil { + if parent != nil { + C.g_object_unref(C.gpointer(parent)) + } + err := nativeError("document context parent", nativeErr) + C.g_error_free(nativeErr) + return DocumentContext{}, err + } + C.g_object_unref(C.gpointer(source)) + source = parent + } + tree, err := NewAccessibleTree(child, nodes) + if err != nil { + return DocumentContext{}, err + } + ref, err := tree.Reference(focus) + if err != nil { + return DocumentContext{}, err + } + return tree.DocumentContext(ref) +} + +func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position ObjectReference, focus ObjectID, offset int, useOffset bool) error { + if err := ctx.Err(); err != nil { + return err + } + adapter.treeMutex.Lock() + tree := adapter.documentTree + adapter.treeMutex.Unlock() + if tree == nil { + return ErrStaleObject + } + snapshot, err := tree.Resolve(position) + if err != nil { + return err + } + source := adapter.nativeDocument[position.ID()] + if source == nil { + return ErrStaleObject + } + // Recheck on the GLib owner: focus can change after the worker queues us. + if len(adapter.nativeFocus) > 0 { + latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id + if latest != focus { + if err := documentPositionMatchesFocus(tree, position, latest); err != nil { + return err + } + } + } + echoIDs, err := tree.subtreeIDs(position) + if err != nil { + return err + } + echoIDs = append(echoIDs, documentCaretEchoAncestors(tree, position)...) + adapter.navigationCaretOffset = offset + adapter.navigationEcho = make(map[ObjectID]uint8, len(echoIDs)) + for _, id := range echoIDs { + adapter.navigationEcho[id] = 7 + } + component := C.atspi_accessible_get_component_iface(source) + if component != nil { + if documentTargetGrabsFocus(tree, position, snapshot) { + C.atspi_component_grab_focus(component, nil) + } + C.atspi_component_scroll_to(component, C.ATSPI_SCROLL_ANYWHERE, nil) + C.g_object_unref(C.gpointer(component)) + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + clear(adapter.navigationEcho) + // Images and other whole-object stops have a valid virtual position + // without a native text caret. Python's web setCaretPosition likewise + // only attempts SetCaretOffset when the object supports text. + adapter.rememberNativeFocus(source, position.ID()) + return nil + } + defer C.g_object_unref(C.gpointer(text)) + var nativeErr *C.GError + var caretOffset C.gint + if useOffset { + caretOffset = C.int(offset) + } else { + caretOffset = C.atspi_text_get_caret_offset(text, &nativeErr) + if nativeErr != nil { + err := nativeError("get document caret", nativeErr) + C.g_error_free(nativeErr) + clear(adapter.navigationEcho) + return err + } + if caretOffset < 0 { + caretOffset = 0 + } + } + adapter.navigationCaretOffset = int(caretOffset) + // Python clears the previous selection before moving its browse caret. + for selections := min(int(C.atspi_text_get_n_selections(text, nil)), 16); selections > 0; selections-- { + C.atspi_text_remove_selection(text, 0, nil) + } + moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr) + if nativeErr != nil { + err := nativeError("set document caret", nativeErr) + C.g_error_free(nativeErr) + clear(adapter.navigationEcho) + return err + } + if moved == 0 { + clear(adapter.navigationEcho) + return errors.New("browser refused document caret") + } + adapter.rememberNativeFocus(source, position.ID()) + return nil +} + +// Python's web grabFocusWhenSettingCaret avoids moving native focus onto +// ordinary headings and entries. Focusing a heading inside a disclosure can +// focus its button and close the disclosure while the browse caret moves. +func documentTargetGrabsFocus(tree *AccessibleTree, position ObjectReference, snapshot Snapshot) bool { + if !snapshot.Focusable || snapshot.Editable || snapshot.Role == "entry" { + return false + } + if snapshot.Role != "heading" && snapshot.Role != "image" { + return true + } + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(position) + if err != nil { + return false + } + if snapshot.Role == "image" { + for entry.hasParent { + parent, ok := tree.nodes[entry.parent] + if !ok { + return false + } + if parent.snapshot.Role == "link" { + return true + } + entry = parent + } + return false + } + if len(entry.children) != 1 { + return false + } + child, ok := tree.nodes[entry.children[0]] + return ok && child.snapshot.Role == "link" +} + +// Same-document focus changes caused by navigation are valid; chrome and other +// tabs must never be pulled back by a queued native caret/focus operation. +func documentPositionMatchesFocus(tree *AccessibleTree, position ObjectReference, focus ObjectID) error { + current, err := tree.Reference(focus) + if err != nil { + return err + } + owner, err := tree.DocumentContext(current) + if err != nil { + return err + } + target, err := tree.DocumentContext(position) + if err != nil { + return err + } + if owner.TopLevel != target.TopLevel { + return errors.New("document focus changed before navigation") + } + return nil +} + +// Foreground returns owned metadata without native calls from a consumer. diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go new file mode 100644 index 0000000..086acb7 --- /dev/null +++ b/internal/atspi/adapter_event_callback_native.go @@ -0,0 +1,513 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "net/url" + "runtime/cgo" + "strings" + "time" + "unsafe" +) + +func shouldSpeakEvent(eventType string, detail1 int) bool { + return eventType != FocusedStateEvent || detail1 == 1 +} + +// Every completion that can acquire document focus needs a retained native +// source for the subsequent context lookup, even without load-complete. +func shouldRetainNativeFocus(eventType string, detail1 int, chromeFocus ObjectID) bool { + switch eventType { + case FocusEvent, FocusedStateEvent, WindowActivatedEvent, ActiveDescendantEvent, SelectionChangedEvent, CaretEvent: + return true + case DocumentLoadCompleteEvent, DocumentLoadStoppedEvent: + return !chromeFocus.Valid() + case BusyStateEvent: + return detail1 == 0 && !chromeFocus.Valid() + default: + return false + } +} + +// selectNativeEventTarget returns a borrowed event source or a retained target. +// The callback releases a retained target after all native queries finish. +func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAccessible, bool) { + target := event.source + ownedTarget := false + switch eventType { + case FocusEvent, FocusedStateEvent: + target = C.cthulhu_combo_ancestor(event.source) + if target == nil { + target = event.source + } else { + ownedTarget = true + } + // 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) + if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_TABLE { + var child *C.AtspiAccessible + if targetRole == C.ATSPI_ROLE_TABLE { + child = C.cthulhu_table_selected_child(target) + } else { + child = C.cthulhu_selected_child(target) + } + if child != nil { + if ownedTarget { + C.g_object_unref(C.gpointer(target)) + } + target = child + ownedTarget = true + } + } + case WindowActivatedEvent: + C.cthulhu_refresh_frame_children(event.source) + target = C.cthulhu_popup_menu_target(event.source) + if target == nil { + target = C.cthulhu_focused_descendant(event.source) + } + if target == nil { + target = C.cthulhu_showing_web_document(event.source) + } + if target != nil { + ownedTarget = true + } else { + target = event.source + } + case ActiveDescendantEvent: + target = C.cthulhu_event_active_descendant(event) + case SelectionChangedEvent: + if C.atspi_accessible_get_role(event.source, nil) == C.ATSPI_ROLE_PAGE_TAB_LIST { + // libatspi resolves the selected child's index through its cached + // 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 { + target = C.cthulhu_table_selected_child(event.source) + } else { + target = C.cthulhu_selected_child(event.source) + } + ownedTarget = target != nil + } + return target, ownedTarget +} + +// captureNativeEventSnapshot enriches the target without changing event ownership. +// It runs on the GLib owner and retains no native references. +func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID) (Snapshot, ObjectID) { + // Document focus/load presentation needs only the title. Real caret + // events still capture text, including carets hosted on document roots. + captureCaret := !isDocumentLifecycleEvent(eventType) && eventType != BusyStateEvent + if eventType == FocusEvent || eventType == FocusedStateEvent { + role := C.atspi_accessible_get_role(target, nil) + captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED + } + snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load()) + snapshot.ApplicationProcess = adapter.applicationProcess(target) + if snapshot.Role == "combo box" && strings.EqualFold(snapshot.ApplicationProcess, "firefox") { + snapshot.Autocomplete = snapshot.Autocomplete || + isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0) + } + if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent { + key := C.CString("container-live") + live := C.cthulhu_live_attribute(event.source, key) + C.free(unsafe.Pointer(key)) + snapshot.LivePoliteness = sanitizeSnapshotField(C.GoString(live)) + snapshot.LiveRegion = snapshot.LivePoliteness != "" && C.cthulhu_in_active_document(event.source) != 0 + C.g_free(C.gpointer(live)) + if snapshot.LiveRegion { + key = C.CString("container-atomic") + atomic := C.cthulhu_live_attribute(event.source, key) + C.free(unsafe.Pointer(key)) + snapshot.LiveAtomic = C.GoString(atomic) == "true" + C.g_free(C.gpointer(atomic)) + if eventType == TextInsertedEvent && snapshot.LiveAtomic { + text := C.cthulhu_live_content(event.source) + snapshot.Text = sanitizeSnapshotField(C.GoString(text)) + C.g_free(C.gpointer(text)) + } + if eventType == ChildrenAddedEvent { + if child := C.cthulhu_event_active_descendant(event); child != nil { + text := C.cthulhu_live_content(child) + if snapshot.LiveAtomic { + C.g_free(C.gpointer(text)) + text = C.cthulhu_live_content(event.source) + } + snapshot.Text = sanitizeSnapshotField(C.GoString(text)) + C.g_free(C.gpointer(text)) + } + } + } + } + if snapshot.BusName == "" { + snapshot.BusName = originID.BusName + } + if eventType == CaretEvent { + adapter.repairNativeCaretLine(target, &snapshot, int(C.cthulhu_event_detail1(event))) + } + if eventType == AccessibleNameEvent || snapshot.Role == "page tab" { + toolkit := C.atspi_accessible_get_toolkit_name(target, nil) + snapshot.ToolkitName = sanitizeSnapshotField(C.GoString(toolkit)) + C.g_free(C.gpointer(toolkit)) + } + if snapshot.Role == "page tab" { + document := C.cthulhu_document_ancestor(target) + snapshot.BrowserTab = isBrowserChromeTab(snapshot, document != nil) + if document != nil { + C.g_object_unref(C.gpointer(document)) + } + } + if (eventType == WindowCreateEvent || eventType == WindowActivatedEvent) && snapshot.Role == "frame" { + if labels := C.cthulhu_collect_simple_prompt_text(target); labels != nil { + snapshot.Text = sanitizeSnapshotField(C.GoString(labels)) + C.g_free(C.gpointer(labels)) + } + } + if eventType == WindowCreateEvent { + if snapshot.Role == "alert" { + if labels := C.cthulhu_collect_notification_text(target); labels != nil { + snapshot.Text = sanitizeSnapshotField(C.GoString(labels)) + C.g_free(C.gpointer(labels)) + } + } + switch strings.ToLower(snapshot.ApplicationName) { + case "notification-daemon", "notify-osd", "xfce4-notifyd", "mate-notification-daemon": + if labels := C.cthulhu_collect_notification_text(target); labels != nil { + snapshot.Text = sanitizeSnapshotField(C.GoString(labels)) + C.g_free(C.gpointer(labels)) + } + } + } + if snapshot.Autocomplete && (eventType == TextInsertedEvent || eventType == TextDeletedEvent || + eventType == FocusEvent || eventType == FocusedStateEvent) { + text := C.atspi_accessible_get_text(target) + if text != nil && C.atspi_text_get_n_selections(text, nil) > 0 { + selection := C.atspi_text_get_selection(text, 0, nil) + if selection != nil { + snapshot.SelectionStart, snapshot.SelectionEnd = int(selection.start_offset), int(selection.end_offset) + if selection.start_offset > 0 && selection.end_offset == C.atspi_text_get_character_count(text, nil) { + prefix := C.atspi_text_get_text(text, selection.start_offset-1, selection.start_offset, nil) + snapshot.CompletionPrefixCharacter = sanitizeCaretCharacter(C.GoString(prefix)) + C.g_free(C.gpointer(prefix)) + } + C.g_free(C.gpointer(selection)) + } + } + if text != nil { + C.g_object_unref(C.gpointer(text)) + } + } + + if eventType == CaretEvent && snapshot.Role != "password text" { + word := C.cthulhu_capture_caret_unit(target, C.ATSPI_TEXT_GRANULARITY_WORD) + snapshot.CaretWord = sanitizeSnapshotField(C.GoString(word)) + C.g_free(C.gpointer(word)) + } + var popupFor ObjectID + if snapshot.Role == "list item" || snapshot.Role == "list box" || snapshot.Role == "menu item" || + snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" { + if popupTarget := C.cthulhu_autocomplete_popup_target(target); popupTarget != nil { + popupFor = nativeObjectID(popupTarget) + snapshot.AutocompletePopup = popupFor.Valid() + C.g_object_unref(C.gpointer(popupTarget)) + } + } + return snapshot, popupFor +} + +// 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) { + webNotification := focusEvent.WebNotification + window := C.cthulhu_window_ancestor(event.source) + var restoredDocument ObjectID + if window == nil && (eventType == FocusedStateEvent || eventType == FocusEvent || eventType == CaretEvent) { + window, restoredDocument = adapter.detachedDocumentWindow(event.source, eventType, focusEvent.Detail1) + } + focusEvent.Window = nativeObjectID(window) + if webNotification != nil { + webNotification.WindowPath = focusEvent.Window.Path + if adapter.webNotificationRoots == nil || len(adapter.webNotificationRoots) >= 64 { + adapter.webNotificationRoots = make(map[ObjectID]bool) + } + adapter.webNotificationRoots[NewObjectID(webNotification.BusName, webNotification.RootPath)] = true + } + focusEvent.WindowRecovered = restoredDocument.Valid() + if eventType == WindowActivatedEvent && (snapshot.Role == "tree table" || snapshot.Role == "table cell") && window != nil { + if labels := C.cthulhu_collect_simple_prompt_text(window); labels != nil { + focusEvent.WindowMessage = sanitizeSnapshotField(C.GoString(labels)) + C.g_free(C.gpointer(labels)) + } + if focusEvent.WindowMessage == "" && focusEvent.Window.Valid() { + adapter.promptRefreshWindow = focusEvent.Window + } + } + if eventType == WindowActivatedEvent && window != nil { + name := C.atspi_accessible_get_name(window, nil) + focusEvent.WindowTitle = sanitizeSnapshotField(C.GoString(name)) + C.g_free(C.gpointer(name)) + } + focusEvent.Foreground = adapter.Foreground() + focusedMenuItem := (eventType == FocusEvent || eventType == FocusedStateEvent) && + snapshot.Role == "menu item" && snapshot.Focused + if eventType == WindowActivatedEvent { + focusEvent.ForegroundMismatch = !adapter.matchForeground(window, focusEvent.Window, &focusEvent.ForegroundCheck, false) + } else { + focusEvent.ForegroundMismatch = !adapter.matchForeground(window, focusEvent.Window, nil, focusedMenuItem) + } + 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)) + } + if !focusEvent.ForegroundMismatch && + (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent || + eventType == SelectionChangedEvent || eventType == ActiveDescendantEvent || + eventType == AccessibleNameEvent && snapshot.Role == "table cell") && + (snapshot.Role == "table" || snapshot.Role == "table cell") { + var rows, columns C.gint + var tableName, tablePath *C.gchar + var rowIndex, columnIndex C.gint + var guiTable C.gboolean + rowText := C.cthulhu_table_context(target, &rows, &columns, &tableName, + &tablePath, &rowIndex, &columnIndex, &guiTable) + snapshot.TableRowCount = int(rows) + snapshot.TableColumnCount = int(columns) + snapshot.TableName = sanitizeSnapshotField(C.GoString(tableName)) + snapshot.TablePath = sanitizeSnapshotField(C.GoString(tablePath)) + snapshot.TableRowIndex = int(rowIndex) + snapshot.TableColumnIndex = int(columnIndex) + snapshot.TablePositionKnown = rowIndex >= 0 && columnIndex >= 0 + snapshot.TableRowText = sanitizeSnapshotField(C.GoString(rowText)) + snapshot.GUITable = guiTable != 0 + C.g_free(C.gpointer(tableName)) + C.g_free(C.gpointer(tablePath)) + C.g_free(C.gpointer(rowText)) + focusEvent.Source = snapshot + } + if !focusEvent.ForegroundMismatch { + if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent { + snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target) + for _, ancestor := range snapshot.SpeechContext { + if ancestor.Role == "document spreadsheet" { + snapshot.Spreadsheet = true + } + } + focusEvent.Source = snapshot + } + if snapshot.BrowserTab && (eventType == SelectedStateEvent && focusEvent.Detail1 != 0 && snapshot.Selected || + eventType == SelectionChangedEvent && snapshot.Selected && strings.EqualFold(snapshot.ToolkitName, "Gecko")) { + adapter.selectedTabWindow = focusEvent.Window + adapter.activeDocument = ObjectID{} + } + if IsWebDocumentRole(snapshot.Role) && + (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == DocumentLoadCompleteEvent) { + adapter.rememberDocumentWindow(snapshot.ID(), window) + adapter.activeDocument = snapshot.ID() + adapter.selectedTabWindow = ObjectID{} + } else if restoredDocument.Valid() { + adapter.activeDocument = restoredDocument + } + } + if (eventType == WindowCreateEvent || eventType == WindowActivatedEvent) && snapshot.Role == "frame" { + adapter.promptRefreshWindow = ObjectID{} + if !focusEvent.ForegroundMismatch && snapshot.Text == "" && focusEvent.Window.Valid() { + adapter.promptRefreshWindow = focusEvent.Window + } + if eventType == WindowActivatedEvent && !focusEvent.ForegroundMismatch { + adapter.promptScannedWindow = focusEvent.Window + adapter.promptScannedEpoch = focusEvent.Foreground.Epoch + } + } + if eventType == WindowActivatedEvent && snapshot.Role == "button" && + !focusEvent.ForegroundMismatch && window != nil && focusEvent.Window.Valid() && + (adapter.promptScannedWindow != focusEvent.Window || adapter.promptScannedEpoch != focusEvent.Foreground.Epoch) { + adapter.promptScannedWindow = focusEvent.Window + adapter.promptScannedEpoch = focusEvent.Foreground.Epoch + if labels := C.cthulhu_collect_simple_prompt_text(window); labels != nil { + focusEvent.WindowMessage = sanitizeSnapshotField(C.GoString(labels)) + C.g_free(C.gpointer(labels)) + } + if focusEvent.WindowMessage == "" { + adapter.promptRefreshWindow = focusEvent.Window + } else { + adapter.promptRefreshWindow = ObjectID{} + } + } + if !focusEvent.ForegroundMismatch && window != nil && + (eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 == 1) && + (snapshot.Role == "button" || snapshot.Role == "tree table" || snapshot.Role == "table cell") && focusEvent.Window == adapter.promptRefreshWindow { + if labels := C.cthulhu_collect_simple_prompt_text(window); labels != nil { + focusEvent.WindowMessage = sanitizeSnapshotField(C.GoString(labels)) + C.g_free(C.gpointer(labels)) + if focusEvent.WindowMessage != "" { + adapter.promptRefreshWindow = ObjectID{} + } + } + } + if window != nil { + C.g_object_unref(C.gpointer(window)) + } +} + +//export cthulhuAtspiEventCallback +func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { + if event == nil || userData == nil { + return + } + callbackHandle := cgo.Handle(C.cthulhu_callback_value((*C.cthulhu_callback_data)(userData))) + value := callbackHandle.Value() + adapter, validAdapter := value.(*Adapter) + if !validAdapter { + return + } + secureEpoch := adapter.secureEpoch.Load() + if adapter.secure.Load() { + return + } + + adapter.applySecureReset() + adapter.refreshForeground() + nativeEventType := C.GoString(C.cthulhu_event_type(event)) + eventType := canonicalTextChangeEventType(nativeEventType) + originID := nativeObjectID(event.source) + if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { + adapter.forgetDocumentWindow(originID) + } else if eventType == WindowDestroyedEvent { + adapter.forgetWindowDocuments(originID) + } + invalidated := adapter.applyTreeInvalidation(eventType, event) + if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 && adapter.webNotificationRoots[originID] { + delete(adapter.webNotificationRoots, originID) + // Retire a known toast before the normal dead-object early return. + // Do not query or retain the accessible which has just been destroyed. + retired := NewEvent(eventType, Snapshot{BusName: originID.BusName, Path: originID.Path, Defunct: true}) + retired.Detail1 = 1 + adapter.enqueueSecureEvent(retired, secureEpoch) + return + } + // Showing/child-added events normally finish at invalidation. A matched + // notification script needs their semantics before that early return, + // including when the provider is outside foreground ownership. + webNotification := adapter.webNotification(event) + if invalidated && webNotification == nil && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) { + return + } + if !shouldSpeakEvent(eventType, int(C.cthulhu_event_detail1(event))) { + return + } + if (isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent) && C.cthulhu_active_document(event.source) == 0 { + return + } + snapshotStarted := time.Now() + target, ownedTarget := selectNativeEventTarget(event, eventType) + if target == nil { + return + } + 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) + // 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 + // AT-SPI state filter when no X11 ownership data is available. + progressUpdate := snapshot.Role == "progress bar" && (eventType == ValueChangedEvent || eventType == AccessibleValueEvent) + inactiveProgress := progressUpdate && !adapter.Foreground().Client.Known && C.cthulhu_in_inactive_window(event.source) != 0 + if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent && + eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && !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)) + } + return + } + var chromeFocus ObjectID + if isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent { + chromeFocus = adapter.focusedChrome(snapshot.ID()) + } + + focusEvent := NewEventWithPayload( + eventType, + snapshot, + C.GoString(payload), + ) + if eventType != nativeEventType { + focusEvent.NativeType = nativeEventType + } + focusEvent.Detail1 = int(C.cthulhu_event_detail1(event)) + focusEvent.WebNotification = webNotification + focusEvent.PopupFor = popupFor + adapter.enrichNativeWindowEvent(event, target, eventType, snapshot, &focusEvent) + if inactiveProgress { + focusEvent.ForegroundMismatch = true + } + if !focusEvent.Window.Valid() && (eventType == WindowActivatedEvent || eventType == WindowDeactivatedEvent || eventType == WindowDestroyedEvent) { + focusEvent.Window = originID + } + if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" { + if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) { + adapter.rememberNativeFocus(target, snapshot.ID()) + } + } + focusEvent.FocusOutsideDocument = chromeFocus + if eventType == DocumentLoadCompleteEvent { + uri := C.cthulhu_document_uri(event.source) + if uri != nil { + if parsed, err := url.Parse(C.GoString(uri)); err == nil { + focusEvent.DocumentFragment = sanitizeSnapshotField(parsed.Fragment) + } + C.g_free(C.gpointer(uri)) + } + } + focusEvent.SnapshotDuration = time.Since(snapshotStarted) + bit := uint8(0) + switch eventType { + case FocusEvent: + bit = 1 + case FocusedStateEvent: + bit = 2 + case CaretEvent: + if focusEvent.Detail1 == adapter.navigationCaretOffset { + bit = 4 + } + } + if adapter.navigationEcho[snapshot.ID()]&bit != 0 { + focusEvent.NavigationEcho = true + adapter.navigationEcho[snapshot.ID()] &^= bit + } else if eventType == CaretEvent || eventType == FocusEvent || eventType == FocusedStateEvent { + clear(adapter.navigationEcho) + } + focusEvent.OriginPath = sanitizeSnapshotField(C.GoString(C.cthulhu_accessible_path(event.source))) + C.g_free(C.gpointer(payload)) + if ownedTarget { + C.g_object_unref(C.gpointer(target)) + } + + adapter.enqueueSecureEvent(focusEvent, secureEpoch) +} diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c new file mode 100644 index 0000000..db011fc --- /dev/null +++ b/internal/atspi/adapter_events.c @@ -0,0 +1,582 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "adapter_native.h" + +extern void cthulhuAtspiEventCallback(AtspiEvent *event, void *userData); +extern void cthulhuAtspiTreeCaptureCallback(void *userData); +extern void cthulhuAtspiForegroundCallback(void *userData); +static gboolean foreground_tick(gpointer data) { cthulhuAtspiForegroundCallback(data); return G_SOURCE_CONTINUE; } +guint start_foreground_timer(gpointer data) { return g_timeout_add(50, foreground_tick, data); } + +const gchar *cthulhu_event_type(AtspiEvent *event) { + return event->type; +} + +gint cthulhu_event_detail1(AtspiEvent *event) { + return event->detail1; +} + +gchar *cthulhu_document_uri(AtspiAccessible *source) { + AtspiDocument *document = atspi_accessible_get_document_iface(source); + if (document == NULL) return NULL; + GError *error = NULL; + gchar *uri = atspi_document_get_attribute_value(document, "DocURL", &error); + if (error != NULL) { + g_clear_error(&error); + g_free(uri); + uri = NULL; + } + if (uri == NULL || *uri == '\0') { + g_free(uri); + uri = atspi_document_get_attribute_value(document, "URI", &error); + if (error != NULL) { + g_clear_error(&error); + g_free(uri); + uri = NULL; + } + } + g_object_unref(document); + return uri; +} + +gchar *cthulhu_event_payload(AtspiEvent *event) { + if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_STRING(&event->any_data)) { + return NULL; + } + return g_value_dup_string(&event->any_data); +} + +gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key) { + GError *error = NULL; + GHashTable *attributes = atspi_accessible_get_attributes(source, &error); + if (error != NULL) g_clear_error(&error); + if (attributes == NULL) return NULL; + gchar *value = g_strdup(g_hash_table_lookup(attributes, key)); + g_hash_table_unref(attributes); + return value; +} + +AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event) { + if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) { + return NULL; + } + GObject *object = g_value_get_object(&event->any_data); + if (object == NULL || !ATSPI_IS_ACCESSIBLE(object)) { + return NULL; + } + return ATSPI_ACCESSIBLE(object); +} + +AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source) { + AtspiSelection *selection = atspi_accessible_get_selection_iface(source); + if (selection == NULL) { + return NULL; + } + GError *error = NULL; + AtspiAccessible *child = atspi_selection_get_selected_child(selection, 0, &error); + g_object_unref(selection); + if (error != NULL) { + g_error_free(error); + return NULL; + } + return child; +} + +// 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) { + 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); + } + return ambiguous ? NULL : cthulhu_selected_child(source); +} + +// Resolve the focused descendant at a window-activation boundary. Providers +// such as Steam's Chromium UI can activate a frame without emitting a usable +// object-focus event. Keep the synchronous callback work bounded so a broken +// or unusually large accessible tree cannot hold the AT-SPI event loop +// indefinitely. +typedef gboolean (*cthulhu_descendant_matcher)(AtspiAccessible *source); + +static gboolean cthulhu_is_focused_descendant(AtspiAccessible *source) { + AtspiStateSet *states = atspi_accessible_get_state_set(source); + gboolean matches = states != NULL && + atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) && + !atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); + if (states != NULL) g_object_unref(states); + return matches; +} + +static gboolean cthulhu_is_showing_web_document(AtspiAccessible *source) { + GError *error = NULL; + AtspiRole role = atspi_accessible_get_role(source, &error); + if (error != NULL) { + g_clear_error(&error); + return FALSE; + } + if (role != ATSPI_ROLE_DOCUMENT_WEB && role != ATSPI_ROLE_DOCUMENT_FRAME && + role != ATSPI_ROLE_EMBEDDED) return FALSE; + AtspiStateSet *states = atspi_accessible_get_state_set(source); + gboolean matches = 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); + return matches; +} + +static AtspiAccessible *cthulhu_find_matching_descendant( + AtspiAccessible *current, guint *visited, guint depth, + cthulhu_descendant_matcher matches) { + if (current == NULL || *visited >= 1024 || depth >= 128) return NULL; + (*visited)++; + if (matches(current)) return g_object_ref(current); + + GError *error = NULL; + gint childCount = atspi_accessible_get_child_count(current, &error); + if (error != NULL) { + g_clear_error(&error); + return NULL; + } + for (gint index = 0; index < childCount && *visited < 1024; index++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(current, index, &error); + if (error != NULL) { + g_clear_error(&error); + continue; + } + if (child == NULL) continue; + AtspiAccessible *result = cthulhu_find_matching_descendant( + child, visited, depth + 1, matches); + g_object_unref(child); + if (result != NULL) return result; + } + return NULL; +} + +static AtspiAccessible *cthulhu_matching_descendant( + AtspiAccessible *window, cthulhu_descendant_matcher matches) { + if (window == NULL) return NULL; + guint visited = 0; + GError *error = NULL; + gint childCount = atspi_accessible_get_child_count(window, &error); + if (error != NULL) { + g_clear_error(&error); + return NULL; + } + for (gint index = 0; index < childCount && visited < 1024; index++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(window, index, &error); + if (error != NULL) { + g_clear_error(&error); + continue; + } + if (child == NULL) continue; + AtspiAccessible *result = cthulhu_find_matching_descendant(child, &visited, 0, matches); + g_object_unref(child); + if (result != NULL) return result; + } + return NULL; +} + +void cthulhu_refresh_frame_children(AtspiAccessible *window) { + // Chromium may advertise a socket-backed child before its accessibility + // tree is initialized. Reading frame attributes activates the child tree; + // clear the stale child cache before asking for descendants. + gchar *toolkit = atspi_accessible_get_toolkit_name(window, NULL); + gboolean chromium = g_strcmp0(toolkit, "Chromium") == 0; + g_free(toolkit); + if (!chromium) return; + + GError *attributesError = NULL; + GHashTable *attributes = atspi_accessible_get_attributes(window, &attributesError); + if (attributesError != NULL) g_clear_error(&attributesError); + if (attributes != NULL) g_hash_table_unref(attributes); + atspi_accessible_clear_cache_single(window); +} + +static AtspiAccessible *cthulhu_collection_descendant(AtspiAccessible *window, + gboolean webDocument, gboolean *answered) { + *answered = FALSE; + AtspiCollection *collection = atspi_accessible_get_collection_iface(window); + if (collection != NULL) { + AtspiStateSet *states = atspi_state_set_new(NULL); + atspi_state_set_add(states, webDocument ? ATSPI_STATE_SHOWING : ATSPI_STATE_FOCUSED); + GArray *roles = NULL; + if (webDocument) { + roles = g_array_new(FALSE, FALSE, sizeof(AtspiRole)); + AtspiRole documentRoles[] = {ATSPI_ROLE_DOCUMENT_WEB, + ATSPI_ROLE_DOCUMENT_FRAME, ATSPI_ROLE_EMBEDDED}; + g_array_append_vals(roles, documentRoles, G_N_ELEMENTS(documentRoles)); + } + AtspiMatchRule *rule = atspi_match_rule_new(states, + ATSPI_Collection_MATCH_ALL, NULL, ATSPI_Collection_MATCH_ALL, + roles, webDocument ? ATSPI_Collection_MATCH_ANY : ATSPI_Collection_MATCH_ALL, NULL, + ATSPI_Collection_MATCH_ALL, FALSE); + GError *error = NULL; + GArray *matches = atspi_collection_get_matches(collection, rule, + ATSPI_Collection_SORT_ORDER_CANONICAL, 1, TRUE, &error); + AtspiAccessible *target = NULL; + *answered = error == NULL && matches != NULL; + if (error == NULL && matches != NULL && matches->len > 0) { + AtspiAccessible *candidate = g_array_index(matches, AtspiAccessible *, 0); + if (candidate != NULL) { + if (webDocument ? cthulhu_is_showing_web_document(candidate) : + cthulhu_is_focused_descendant(candidate)) { + target = g_object_ref(candidate); + } + } + } + if (error != NULL) g_clear_error(&error); + if (matches != NULL) g_array_unref(matches); + g_object_unref(rule); + g_object_unref(states); + if (roles != NULL) g_array_unref(roles); + g_object_unref(collection); + return target; + } + return NULL; +} + +AtspiAccessible *cthulhu_focused_descendant(AtspiAccessible *window) { + // An empty successful Collection reply is conclusive. GTK briefly has + // no focused child when a combo popup closes; walking the entire window + // then delays the focus event which is already waiting to be processed. + gboolean answered; + AtspiAccessible *target = cthulhu_collection_descendant(window, FALSE, &answered); + if (answered) return target; + return cthulhu_matching_descendant(window, cthulhu_is_focused_descendant); +} + +AtspiAccessible *cthulhu_showing_web_document(AtspiAccessible *window) { + gboolean answered; + AtspiAccessible *target = cthulhu_collection_descendant(window, TRUE, &answered); + if (answered) return target; + return cthulhu_matching_descendant(window, cthulhu_is_showing_web_document); +} + +// Return the autocomplete entry named by a popup-for relation on this object +// or an ancestor. The caller owns the returned reference. +AtspiAccessible *cthulhu_autocomplete_popup_target(AtspiAccessible *source) { + AtspiAccessible *current = g_object_ref(source); + for (int depth = 0; current != NULL && depth < 16; depth++) { + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME || role == ATSPI_ROLE_APPLICATION) { + g_object_unref(current); + return NULL; + } + GArray *relations = atspi_accessible_get_relation_set(current, NULL); + AtspiAccessible *found = NULL; + if (relations != NULL) { + for (guint i = 0; i < relations->len && found == NULL; i++) { + AtspiRelation *relation = g_array_index(relations, AtspiRelation *, i); + if (atspi_relation_get_relation_type(relation) != ATSPI_RELATION_POPUP_FOR) continue; + for (gint j = 0; j < atspi_relation_get_n_targets(relation); j++) { + AtspiAccessible *target = atspi_relation_get_target(relation, j); + if (target != NULL) { + AtspiRole targetRole = atspi_accessible_get_role(target, NULL); + AtspiStateSet *states = atspi_accessible_get_state_set(target); + gboolean autocomplete = targetRole == ATSPI_ROLE_AUTOCOMPLETE || (targetRole == ATSPI_ROLE_ENTRY && states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SUPPORTS_AUTOCOMPLETION)); + if (states != NULL) g_object_unref(states); + if (autocomplete) found = target; + else g_object_unref(target); + } + if (found != NULL) break; + } + } + for (guint i = 0; i < relations->len; i++) { + g_object_unref(g_array_index(relations, AtspiRelation *, i)); + } + g_array_unref(relations); + } + if (found != NULL) { g_object_unref(current); return found; } + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + return NULL; +} + +AtspiAccessible *cthulhu_combo_ancestor(AtspiAccessible *source) { + if (source == NULL) return NULL; + GError *error = NULL; + AtspiRole source_role = atspi_accessible_get_role(source, &error); + if (error != NULL) { + g_error_free(error); + return NULL; + } + if (source_role != ATSPI_ROLE_TOGGLE_BUTTON) return NULL; + + AtspiAccessible *current = g_object_ref(source); + for (int depth = 0; current != NULL && depth < 4; depth++) { + error = NULL; + AtspiRole role = atspi_accessible_get_role(current, &error); + if (error != NULL) { + g_error_free(error); + g_object_unref(current); + return NULL; + } + if (role == ATSPI_ROLE_COMBO_BOX) { + return current; + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, &error); + g_object_unref(current); + if (error != NULL) { + g_error_free(error); + return NULL; + } + current = parent; + } + if (current != NULL) { + g_object_unref(current); + } + return NULL; +} + +// Announce only a showing top-level document in the active window. +// Background tabs and embedded frame loads must not interrupt the user. +gboolean cthulhu_active_document(AtspiAccessible *source) { + if (source == NULL) return FALSE; + AtspiStateSet *states = atspi_accessible_get_state_set(source); + gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING); + if (states != NULL) g_object_unref(states); + if (!showing) return FALSE; + AtspiAccessible *current = g_object_ref(source); + for (int depth = 0; current != NULL && depth < 256; depth++) { + GError *error = NULL; + AtspiRole role = atspi_accessible_get_role(current, &error); + if (error != NULL) { g_error_free(error); g_object_unref(current); return FALSE; } + gboolean document = role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME || role == ATSPI_ROLE_EMBEDDED; + if ((depth == 0 && !document) || (depth > 0 && document)) { + g_object_unref(current); + return FALSE; + } + if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) { + states = atspi_accessible_get_state_set(current); + gboolean active = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_ACTIVE); + if (states != NULL) g_object_unref(states); + g_object_unref(current); + return active; + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, &error); + g_object_unref(current); + if (error != NULL) { + g_error_free(error); + if (parent != NULL) g_object_unref(parent); + return FALSE; + } + current = parent; + } + if (current != NULL) g_object_unref(current); + return FALSE; +} + +gboolean cthulhu_in_active_document(AtspiAccessible *source) { + AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL; + AtspiAccessible *document = NULL; + for (int depth = 0; current != NULL && depth < 256; depth++) { + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) { + if (document != NULL) g_object_unref(document); + document = g_object_ref(current); + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + if (document == NULL) return FALSE; + gboolean active = cthulhu_active_document(document); + g_object_unref(document); + return active; +} + + +// Some applications continue emitting focus and text events after their X11 +// window loses activation. Check the containing top-level window: an embedded +// web dialog need not carry ACTIVE even when its frame is active. Missing state +// is inconclusive. +gboolean cthulhu_in_inactive_window(AtspiAccessible *source) { + AtspiAccessible *window = cthulhu_window_ancestor(source); + if (window == NULL) return FALSE; + AtspiStateSet *states = atspi_accessible_get_state_set(window); + gboolean inactive = states != NULL && !atspi_state_set_contains(states, ATSPI_STATE_ACTIVE); + if (states != NULL) g_object_unref(states); + g_object_unref(window); + return inactive; +} + +// GTK menus can live in their own AT-SPI window while X11 continues to name +// the invoking application window as the foreground client. Python Cthulhu +// accepts an active, showing window with one menu child in this situation. +gboolean cthulhu_active_menu_window(AtspiAccessible *window) { + if (window == NULL) return FALSE; + AtspiRole role = atspi_accessible_get_role(window, NULL); + if (role != ATSPI_ROLE_WINDOW && role != ATSPI_ROLE_FRAME) return FALSE; + AtspiStateSet *states = atspi_accessible_get_state_set(window); + gboolean active = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_ACTIVE); + gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING); + if (states != NULL) g_object_unref(states); + if (!active || !showing || atspi_accessible_get_child_count(window, NULL) != 1) return FALSE; + AtspiAccessible *child = atspi_accessible_get_child_at_index(window, 0, NULL); + if (child == NULL) return FALSE; + AtspiRole childRole = atspi_accessible_get_role(child, NULL); + gboolean menu = childRole == ATSPI_ROLE_MENU; + if (role == ATSPI_ROLE_FRAME && childRole == ATSPI_ROLE_MENU_BAR) { + gchar *name = atspi_accessible_get_name(window, NULL); + menu = name == NULL || *name == '\0'; + g_free(name); + } + g_object_unref(child); + return menu; +} + +static gboolean cthulhu_is_menu(AtspiAccessible *source) { + return atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_MENU; +} + +AtspiAccessible *cthulhu_popup_menu_target(AtspiAccessible *window) { + if (!cthulhu_active_menu_window(window)) return NULL; + AtspiAccessible *menu = cthulhu_matching_descendant(window, cthulhu_is_menu); + if (menu == NULL) return NULL; + AtspiAccessible *selected = cthulhu_selected_child(menu); + if (selected != NULL) { + g_object_unref(menu); + return selected; + } + return menu; +} + +const gchar *cthulhu_accessible_path(AtspiAccessible *source) { + return source != NULL ? ATSPI_OBJECT(source)->path : NULL; +} + +const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source) { + if (source == NULL || ATSPI_OBJECT(source)->app == NULL) { + return NULL; + } + return ATSPI_OBJECT(source)->app->bus_name; +} + +// Return the containing top-level window. An embedded web dialog is part of +// its frame; a standalone dialog directly below the application owns itself. +AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) { + if (source == NULL) return NULL; + AtspiAccessible *current = g_object_ref(source); + 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); + return NULL; + } + if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) { + return current; + } + AtspiAccessible *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; + } + g_object_unref(current); + if (error != NULL) { + g_clear_error(&error); + if (parent != NULL) g_object_unref(parent); + return NULL; + } + current = parent; + } + if (current != NULL) g_object_unref(current); + return NULL; +} + +// Chromium can detach an existing document from its frame's AT-SPI parent +// chain while returning to a tab. Its document identity remains usable. +AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) { + AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL; + AtspiAccessible *document = NULL; + for (gint depth = 0; current != NULL && depth < 256; depth++) { + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) { + if (document != NULL) g_object_unref(document); + document = g_object_ref(current); + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + return document; +} + +static void cthulhu_event_callback(AtspiEvent *event, void *userData) { + cthulhuAtspiEventCallback(event, userData); +} + +AtspiEventListenerCB cthulhu_event_callback_pointer(void) { + return cthulhu_event_callback; +} + +static gboolean cthulhu_quit_event_loop(gpointer unused) { + (void) unused; + atspi_event_quit(); + return G_SOURCE_REMOVE; +} + +gboolean cthulhu_request_event_loop_quit(void) { + GSource *source = g_idle_source_new(); + if (source == NULL) { + return FALSE; + } + g_source_set_priority(source, G_PRIORITY_HIGH); + g_source_set_callback(source, cthulhu_quit_event_loop, NULL, NULL); + guint source_id = g_source_attach(source, g_main_context_default()); + g_source_unref(source); + return source_id != 0; +} + +static gboolean cthulhu_capture_tree_callback(gpointer userData) { + cthulhuAtspiTreeCaptureCallback(userData); + return G_SOURCE_REMOVE; +} + +gboolean cthulhu_request_tree_capture(gpointer userData) { + GSource *source = g_idle_source_new(); + if (source == NULL) { + return FALSE; + } + g_source_set_priority(source, G_PRIORITY_HIGH); + g_source_set_callback(source, cthulhu_capture_tree_callback, userData, NULL); + guint source_id = g_source_attach(source, g_main_context_default()); + g_source_unref(source); + return source_id != 0; +} + + + +cthulhu_callback_data *cthulhu_callback_data_new(uintptr_t callback) { + cthulhu_callback_data *data = g_new0(cthulhu_callback_data, 1); + if (data != NULL) { + data->callback = callback; + } + return data; +} + +uintptr_t cthulhu_callback_value(const cthulhu_callback_data *data) { + return data->callback; +} + +void cthulhu_callback_data_free(cthulhu_callback_data *data) { + g_free(data); +} diff --git a/internal/atspi/adapter_foreground_native.go b/internal/atspi/adapter_foreground_native.go new file mode 100644 index 0000000..e806f3d --- /dev/null +++ b/internal/atspi/adapter_foreground_native.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "runtime/cgo" + "unsafe" +) + +func (adapter *Adapter) Foreground() ForegroundState { + state := adapter.foreground.Load() + if state == nil { + return ForegroundState{} + } + return *state +} +func (adapter *Adapter) ForegroundChanges() <-chan struct{} { return adapter.foregroundChanges } +func (adapter *Adapter) refreshForeground() { + value := adapter.foregroundReader.Read() + previous := adapter.Foreground() + if value == previous.Client { + return + } + state := ForegroundState{Client: value, Epoch: previous.Epoch} + if value.Known != previous.Client.Known || value.Window != previous.Client.Window { + state.Epoch++ + adapter.foregroundWindow = ObjectID{} + adapter.selectedTabWindow = ObjectID{} + adapter.activeDocument = ObjectID{} + adapter.foregroundRecovery = 8 + } + adapter.foreground.Store(&state) + select { + case adapter.foregroundChanges <- struct{}{}: + default: + } +} + +//export cthulhuAtspiForegroundCallback +func cthulhuAtspiForegroundCallback(data unsafe.Pointer) { + handle := cgo.Handle(C.cthulhu_callback_value((*C.cthulhu_callback_data)(data))) + adapter := handle.Value().(*Adapter) + adapter.applySecureReset() + if adapter.secure.Load() { + return + } + adapter.refreshForeground() + adapter.refreshWindowTitle() + if adapter.secureResync.Swap(false) { + adapter.foregroundRecovery = 8 + } + adapter.recoverForeground() +} + +func (adapter *Adapter) matchesForeground(window *C.AtspiAccessible, id ObjectID) bool { + return adapter.matchForeground(window, id, nil, false) +} + +func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID, check *ForegroundCheck, focusedMenuItem bool) bool { + state := adapter.Foreground().Client + if !state.Known { + return true + } + if state.Window == 0 || window == nil { + if check != nil { + check.Reason = "no X11 foreground window or accessible window" + } + return false + } + pid := int(C.atspi_accessible_get_process_id(window, nil)) + if check != nil { + check.AccessiblePID = pid + } + if adapter.foregroundWindow.Valid() { + if adapter.foregroundWindow == id { + return true + } + matched := foregroundMenuPopupMatches(state, adapter.foregroundWindow, id, pid, + focusedMenuItem || C.cthulhu_active_menu_window(window) != 0) + if !matched && check != nil { + check.Reason = "different accessible window from correlated foreground" + } + return matched + } + // Same-process maximized windows can have identical bounds. As in + // Python canBeActiveWindow(clearCache=True), only correlate a freshly + // active, showing, non-iconified candidate. Never latch the first sibling + // merely because its geometry matches the X11 client. + C.atspi_accessible_clear_cache_single(window) + states := C.atspi_accessible_get_state_set(window) + if states == nil { + if check != nil { + check.Reason = "accessible window state unavailable" + } + return false + } + active := C.atspi_state_set_contains(states, C.ATSPI_STATE_ACTIVE) != 0 + showing := C.atspi_state_set_contains(states, C.ATSPI_STATE_SHOWING) != 0 + iconified := C.atspi_state_set_contains(states, C.ATSPI_STATE_ICONIFIED) != 0 + C.g_object_unref(C.gpointer(states)) + if check != nil { + check.Active, check.Showing, check.Iconified = active, showing, iconified + } + if !active || !showing || iconified { + if check != nil { + check.Reason = "accessible window inactive, hidden, or iconified" + } + return false + } + component := C.atspi_accessible_get_component_iface(window) + if component == nil { + if check != nil { + check.Reason = "accessible window component unavailable" + } + return false + } + bounds := C.atspi_component_get_extents(component, C.ATSPI_COORD_TYPE_SCREEN, nil) + C.g_object_unref(C.gpointer(component)) + if bounds == nil { + if check != nil { + check.Reason = "accessible window bounds unavailable" + } + return false + } + rectangle := Rectangle{X: int32(bounds.x), Y: int32(bounds.y), Width: int32(bounds.width), Height: int32(bounds.height)} + C.g_free(C.gpointer(bounds)) + if check != nil { + check.Bounds = rectangle + } + if !foregroundWindowMatches(state, pid, rectangle) { + if check != nil { + if state.PID > 0 && pid > 0 && state.PID != pid { + check.Reason = "process ID differs" + } else { + check.Reason = "window bounds differ" + } + } + return false + } + adapter.foregroundWindow = id + adapter.foregroundRecovery = 0 + return true +} + +// Recovery runs only at foreground transitions, off the event consumer. It +// handles returning from an empty workspace when a toolkit retains focus and +// emits no new focused-child event. Searches and retries are bounded. +func (adapter *Adapter) recoverForeground() { + secureEpoch := adapter.secureEpoch.Load() + state := adapter.Foreground() + if adapter.foregroundRecovery == 0 || adapter.recoveringForeground || !state.Client.Known || state.Client.Window == 0 || state.Client.PID <= 0 { + return + } + adapter.foregroundRecovery-- + adapter.recoveringForeground = true + defer func() { adapter.recoveringForeground = false }() + desktop := C.atspi_get_desktop(0) + if desktop == nil { + return + } + defer C.g_object_unref(C.gpointer(desktop)) + count := min(int(C.atspi_accessible_get_child_count(desktop, nil)), 128) + for i := 0; i < count; i++ { + application := C.atspi_accessible_get_child_at_index(desktop, C.gint(i), nil) + if application == nil { + continue + } + pid := int(C.atspi_accessible_get_process_id(application, nil)) + if pid != state.Client.PID { + C.g_object_unref(C.gpointer(application)) + continue + } + windows := min(int(C.atspi_accessible_get_child_count(application, nil)), 128) + for j := 0; j < windows; j++ { + window := C.atspi_accessible_get_child_at_index(application, C.gint(j), nil) + if window == nil { + continue + } + if state.Epoch == adapter.Foreground().Epoch && adapter.matchesForeground(window, nativeObjectID(window)) { + target := C.cthulhu_focused_descendant(window) + if target == nil { + target = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(window))) + } + // Recover the active child just as a normal focus event does. + role := C.atspi_accessible_get_role(target, nil) + var child *C.AtspiAccessible + if role == C.ATSPI_ROLE_TABLE { + child = C.cthulhu_table_selected_child(target) + } + if role == C.ATSPI_ROLE_MENU { + child = C.cthulhu_selected_child(target) + } + if child != nil { + C.g_object_unref(C.gpointer(target)) + target = child + } + snapshot := snapshotFromNativeWithCaret(target, true, false, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load()) + event := NewEvent(WindowActivatedEvent, snapshot) + event.Window = nativeObjectID(window) + name := C.atspi_accessible_get_name(window, nil) + event.WindowTitle = sanitizeSnapshotField(C.GoString(name)) + C.g_free(C.gpointer(name)) + 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)) + C.g_free(C.gpointer(labels)) + } + if event.WindowMessage == "" { + adapter.promptRefreshWindow = event.Window + } + } + event.OriginPath = event.Window.Path + event.Foreground = adapter.Foreground() + adapter.rememberNativeFocus(target, snapshot.ID()) + C.g_object_unref(C.gpointer(target)) + adapter.enqueueSecureEvent(event, secureEpoch) + C.g_object_unref(C.gpointer(window)) + C.g_object_unref(C.gpointer(application)) + return + } + C.g_object_unref(C.gpointer(window)) + } + C.g_object_unref(C.gpointer(application)) + } +} + +// SetSecureInputActive publishes policy to the GLib owner. Native references +// are released there; no caller outside that thread touches AT-SPI objects. diff --git a/internal/atspi/adapter_lifecycle_native.go b/internal/atspi/adapter_lifecycle_native.go new file mode 100644 index 0000000..7bf54bf --- /dev/null +++ b/internal/atspi/adapter_lifecycle_native.go @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "git.stormux.org/storm/cthulhu/internal/platform/session" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "runtime" + "runtime/cgo" + "unsafe" +) + +func (adapter *Adapter) Start(ctx context.Context) error { + if !adapter.running.CompareAndSwap(false, true) { + return errors.New("AT-SPI adapter already started") + } + + adapter.loopDone = make(chan struct{}) + setupDone := make(chan error, 1) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := adapter.initialize(); err != nil { + setupDone <- err + return + } + // Xwayland only describes X clients. Its empty or stale foreground + // must not veto native Wayland windows; use AT-SPI ownership there. + if !session.Wayland() { + adapter.foregroundReader = xorg.OpenForegroundReader() + } + adapter.promptRefreshWindow = ObjectID{} + adapter.promptScannedWindow = ObjectID{} + adapter.promptScannedEpoch = 0 + adapter.refreshForeground() + adapter.refreshWindowTitle() + timer := C.start_foreground_timer(C.gpointer(adapter.callbackData)) + setupDone <- nil + C.atspi_event_main() + if timer != 0 { + C.g_source_remove(timer) + } + adapter.foregroundReader.Close() + adapter.foregroundReader = nil + for _, focus := range adapter.nativeFocus { + C.g_object_unref(C.gpointer(focus.source)) + } + adapter.nativeFocus = nil + for _, known := range adapter.documentWindows { + C.g_object_unref(C.gpointer(known.source)) + } + adapter.documentWindows = nil + adapter.documentWindowIDs = nil + adapter.clearNativeDocument() + adapter.clearNativeReview() + adapter.clearMouseApplication() + close(adapter.loopDone) + }() + + select { + case err := <-setupDone: + if err != nil { + adapter.running.Store(false) + return err + } + if err := ctx.Err(); err != nil { + C.atspi_event_quit() + <-adapter.loopDone + adapter.running.Store(false) + return err + } + adapter.ready.Store(true) + return nil + case <-ctx.Done(): + err := <-setupDone + if err != nil { + adapter.running.Store(false) + return err + } + C.atspi_event_quit() + <-adapter.loopDone + adapter.running.Store(false) + return ctx.Err() + } +} + +func (adapter *Adapter) initialize() error { + if result := C.atspi_init(); result != 0 { + return fmt.Errorf("initialize AT-SPI: result %d", int(result)) + } + // libatspi's ordinary default is 800 ms, but its startup grace period is + // much longer. Keep both bounded so a newly connected or wedged provider + // cannot indefinitely hold the event-loop thread during owned-tree capture. + C.atspi_set_timeout(800, 800) + + callbackHandle := cgo.NewHandle(adapter) + callbackData := C.cthulhu_callback_data_new(C.uintptr_t(callbackHandle)) + if callbackData == nil { + callbackHandle.Delete() + C.atspi_exit() + return errors.New("allocate AT-SPI callback data") + } + adapter.callbackHandle = callbackHandle + adapter.callbackData = callbackData + + for _, eventType := range eventTypes { + if err := adapter.registerEvent(eventType); err != nil { + if cleanupErr := adapter.deregisterEvents(); cleanupErr != nil { + err = errors.Join(err, cleanupErr) + } + C.cthulhu_callback_data_free(callbackData) + adapter.callbackHandle = 0 + adapter.callbackData = nil + C.atspi_exit() + return err + } + } + + return nil +} + +func (adapter *Adapter) registerEvent(eventType string) error { + nativeEventType := C.CString(eventType) + var registerError *C.GError + registered := C.atspi_event_listener_register_from_callback( + C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), + unsafe.Pointer(adapter.callbackData), + nil, + nativeEventType, + ®isterError, + ) + C.free(unsafe.Pointer(nativeEventType)) + if registerError != nil { + err := nativeError("register AT-SPI event listener "+eventType, registerError) + C.g_error_free(registerError) + return err + } + if registered == 0 { + return errors.New("register AT-SPI event listener " + eventType) + } + return nil +} + +func (adapter *Adapter) deregisterEvents() error { + var stopErr error + for _, eventType := range eventTypes { + nativeEventType := C.CString(eventType) + var deregisterError *C.GError + C.atspi_event_listener_deregister_from_callback( + C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), + unsafe.Pointer(adapter.callbackData), + nativeEventType, + &deregisterError, + ) + C.free(unsafe.Pointer(nativeEventType)) + if deregisterError != nil { + stopErr = errors.Join(stopErr, nativeError("deregister AT-SPI event listener "+eventType, deregisterError)) + C.g_error_free(deregisterError) + } + } + return stopErr +} + +func (adapter *Adapter) Stop(ctx context.Context) error { + adapter.stopMutex.Lock() + defer adapter.stopMutex.Unlock() + if !adapter.running.Load() { + return nil + } + adapter.ready.Store(false) + + var stopErr error + // AT-SPI documents event-loop quit as an event-handler operation. Schedule + // it on the default GLib context owned by atspi_event_main instead of calling + // it synchronously from this arbitrary shutdown goroutine. + if C.cthulhu_request_event_loop_quit() == 0 { + stopErr = errors.New("request AT-SPI event loop quit") + } + + select { + case <-adapter.loopDone: + case <-ctx.Done(): + return errors.Join(stopErr, fmt.Errorf("wait for AT-SPI event loop: %w", ctx.Err())) + } + if result := C.atspi_exit(); result != 0 { + stopErr = errors.Join(stopErr, fmt.Errorf("exit AT-SPI: result %d", int(result))) + } + C.cthulhu_callback_data_free(adapter.callbackData) + adapter.callbackData = nil + adapter.callbackHandle.Delete() + adapter.callbackHandle = 0 + adapter.running.Store(false) + adapter.invalidateTree() + return stopErr +} diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h new file mode 100644 index 0000000..4f56963 --- /dev/null +++ b/internal/atspi/adapter_native.h @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef CTHULHU_ATSPI_NATIVE_H +#define CTHULHU_ATSPI_NATIVE_H + +#include +#include + +typedef struct { + const gchar *busName; + gchar *applicationName; + gchar *name; + gchar *role; + gchar *description; + const gchar *path; + gchar *text; + gint textStartOffset; + gchar *textBeforeCaret; + gchar *caretCharacter; + gboolean autocomplete; + gint headingLevel; + gint positionInSet, setSize; + gboolean visible; + gboolean showing; + gboolean statesKnown; + gboolean active; + gboolean focusable; + gboolean focused; + gboolean hasPopup; + gboolean checked; + gboolean defunct; + gboolean editable; + gboolean expandable; + gboolean expanded; + gboolean indeterminate; + gboolean invalid; + gboolean multiselectable; + gboolean readOnly; + gboolean required; + gboolean selected; + gboolean sensitive; + gboolean sensitiveKnown; + gboolean valueKnown; + gboolean visited; + gboolean explicitName; + gdouble currentValue; + gdouble maximumValue; + gdouble minimumValue; + gdouble minimumIncrement; + gchar *valueText; + gchar *xmlRoles; + gchar *htmlTag; + gchar *elementID; + gboolean supportsClick; + gboolean liveRegion; + gboolean layoutTable; + gint childCount; + gboolean childCountKnown; + gint tableRowCount; + gint tableColumnCount, tableRowIndex, tableColumnIndex, tableRowSpan, tableColumnSpan; + gboolean tablePositionKnown; + gchar *tableRowHeaders, *tableColumnHeaders; + gint characterCount; + gint embeddedObjectCount; + gint objectX; + gint objectY; + gint objectWidth; + gint objectHeight; + gint referenceX; + gint referenceY; + gint referenceWidth; + gint referenceHeight; + gboolean objectGeometryKnown; + gboolean referenceGeometryKnown; +} cthulhu_snapshot; + +typedef struct { + uintptr_t callback; +} cthulhu_callback_data; + +gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, + gchar **tableName, gchar **tablePath, + gint *rowIndex, gint *columnIndex, gboolean *guiTable); +cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText, + gboolean captureCaretCharacter, + gboolean captureDocumentMetadata, + gboolean captureSoundGeometry, + gboolean captureApplicationName); +gchar *cthulhu_collect_notification_text(AtspiAccessible *source); +gchar *cthulhu_collect_simple_prompt_text(AtspiAccessible *source); +gchar *cthulhu_radio_group_name(AtspiAccessible *source); +void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attributes, cthulhu_snapshot *snapshot); +gboolean cthulhu_has_set_position(const gchar *role); +gchar *cthulhu_live_content(AtspiAccessible *source); +gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit); +guint start_foreground_timer(gpointer data); +const gchar *cthulhu_event_type(AtspiEvent *event); +gint cthulhu_event_detail1(AtspiEvent *event); +gchar *cthulhu_document_uri(AtspiAccessible *source); +gchar *cthulhu_event_payload(AtspiEvent *event); +gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key); +AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event); +AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source); +AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source); +void cthulhu_refresh_frame_children(AtspiAccessible *window); +AtspiAccessible *cthulhu_focused_descendant(AtspiAccessible *window); +AtspiAccessible *cthulhu_showing_web_document(AtspiAccessible *window); +AtspiAccessible *cthulhu_autocomplete_popup_target(AtspiAccessible *source); +AtspiAccessible *cthulhu_combo_ancestor(AtspiAccessible *source); +gboolean cthulhu_active_document(AtspiAccessible *source); +gboolean cthulhu_in_active_document(AtspiAccessible *source); +gboolean cthulhu_in_inactive_window(AtspiAccessible *source); +gboolean cthulhu_active_menu_window(AtspiAccessible *window); +AtspiAccessible *cthulhu_popup_menu_target(AtspiAccessible *window); +const gchar *cthulhu_accessible_path(AtspiAccessible *source); +const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source); +AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source); +AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source); +AtspiEventListenerCB cthulhu_event_callback_pointer(void); +gboolean cthulhu_request_event_loop_quit(void); +gboolean cthulhu_request_tree_capture(gpointer userData); +cthulhu_callback_data *cthulhu_callback_data_new(uintptr_t callback); +uintptr_t cthulhu_callback_value(const cthulhu_callback_data *data); +void cthulhu_callback_data_free(cthulhu_callback_data *data); + +#endif diff --git a/internal/atspi/adapter_native_test.go b/internal/atspi/adapter_native_test.go new file mode 100644 index 0000000..7fd677d --- /dev/null +++ b/internal/atspi/adapter_native_test.go @@ -0,0 +1,795 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeGUITablePresentation(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_GUI_TABLE") != "1" { + t.Skip("requires isolated GTK table fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + window := findNamedReference(t, tree, "Table presentation probe") + if !window.Valid() { + t.Fatal("GTK table window is absent from AT-SPI desktop") + } + frame, err := tree.Resolve(window) + if err != nil || frame.Description != "Select an item and press Enter to open it." { + t.Fatalf("GTK frame description = %q, %v", frame.Description, err) + } + if err := exec.Command("xdotool", "key", "Down").Run(); err != nil { + t.Fatal(err) + } +selectedRow: + for { + select { + case <-ctx.Done(): + t.Fatal("GTK table selection did not expose row context") + case event := <-adapter.Events(): + if event.Type != SelectionChangedEvent || event.Source.Name != "Beta" { + continue + } + if event.Source.TableName != "Probe items" || event.Source.TableRowCount != 2 || + event.Source.TableColumnCount != 2 || event.Source.TableRowText != "Beta, Shortcut" || + !event.Source.GUITable || !event.Source.TablePositionKnown || + event.Source.TableRowIndex != 1 || event.Source.TableColumnIndex != 0 || + event.Source.TablePath == "" || + event.WindowDescription != "Select an item and press Enter to open it." { + t.Fatalf("GTK selected row context = %#v", event.Source) + } + break selectedRow + } + } + if err := exec.Command("xdotool", "key", "Right").Run(); err != nil { + t.Fatal(err) + } + for { + select { + case <-ctx.Done(): + t.Fatal("GTK table did not expose horizontal cell movement") + case event := <-adapter.Events(): + if event.Type != ActiveDescendantEvent || event.Source.Name != "Shortcut" { + continue + } + if !event.Source.TablePositionKnown || event.Source.TableRowIndex != 1 || + event.Source.TableColumnIndex != 1 { + t.Fatalf("GTK horizontal cell context = %#v", event.Source) + } + return + } + } +} + +func TestNativeGUITableFocusPresentsSelectedChild(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_GUI_TABLE") != "1" { + t.Skip("requires isolated GTK table fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + window := findNamedReference(t, tree, "Table presentation probe") + if !window.Valid() { + t.Fatal("GTK table window is absent from AT-SPI desktop") + } + windowIDs, err := exec.Command("xdotool", "search", "--name", "Table presentation probe").Output() + if err != nil || len(strings.Fields(string(windowIDs))) == 0 { + t.Fatalf("GTK table window not found: %v", err) + } + if err := exec.Command("xdotool", "windowactivate", strings.Fields(string(windowIDs))[0]).Run(); err != nil { + t.Fatal(err) + } + for { + select { + case <-ctx.Done(): + t.Fatal("GTK table focus did not present selected child") + case event := <-adapter.Events(): + if event.Type != FocusEvent && event.Type != FocusedStateEvent { + continue + } + if event.Source.Role != "table cell" || event.Source.Name != "Alpha" { + t.Fatalf("focused table source = %#v", event.Source) + } + if event.Source.TableName != "Probe items" || !event.Source.GUITable || + !event.Source.TablePositionKnown || event.Source.TablePath == "" { + t.Fatalf("focused table context = %#v", event.Source) + } + return + } + } +} + +func TestNativeGUITableMultiselectionFocus(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_GUI_TABLE_MULTI") != "1" { + t.Skip("requires isolated multiselection GTK table fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + window := findNamedReference(t, tree, "Table presentation probe") + if !window.Valid() { + t.Fatal("GTK table window is absent from AT-SPI desktop") + } + for _, name := range []string{"Alpha", "Beta"} { + cell := findNamedReference(t, tree, name) + if !cell.Valid() { + t.Fatalf("%s cell is absent from the multiselection table", name) + } + snapshot, err := tree.Resolve(cell) + if err != nil || !snapshot.Selected { + t.Fatalf("%s cell is not selected: %#v, %v", name, snapshot, err) + } + } + // The fixture starts with a button focused and two table rows selected. + // Tab focuses the table; neither selected row is an unambiguous target. + if err := exec.Command("xdotool", "key", "Tab").Run(); err != nil { + t.Fatal(err) + } + for { + select { + case <-ctx.Done(): + t.Fatal("multiselection table focus event was not received") + case event := <-adapter.Events(): + if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.BusName != window.ID().BusName { + continue + } + if event.Source.Role == "button" || event.Source.Role == "push button" { + continue + } + if event.Source.Role != "table" || event.Source.Name != "Probe items" { + t.Fatalf("multiselection focus guessed a row: %#v", event.Source) + } + return + } + } +} + +// Run as the first accessibility reader in a real Xorg session, then launch +// steam -bigpicture and press Tab twice without leaving Steam. This catches the frame-only provider state +// that a synthetic Chromium fixture cannot reproduce. +func TestNativeSteamBigPictureFocus(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_STEAM") != "1" { + t.Skip("requires a fresh Steam Big Picture launch in a real Xorg session") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + frames := 0 + focused := make(map[ObjectID]string) + var steamWindow uint64 + for { + select { + case <-ctx.Done(): + t.Fatalf("Steam exposed %d frame activations and %d distinct named controls; launch Big Picture and press Tab at least twice before the timeout", frames, len(focused)) + case <-adapter.ForegroundChanges(): + foreground := adapter.Foreground().Client + if steamWindow != 0 && foreground.Known && foreground.Window != steamWindow { + t.Fatal("foreground left Steam before two controls were observed") + } + case event, ok := <-adapter.Events(): + if !ok { + t.Fatal("AT-SPI events stopped before Steam focus was observed") + } + pid := event.Foreground.Client.PID + if steamWindow != 0 && event.Foreground.Client.Known && + event.Foreground.Client.Window != 0 && event.Foreground.Client.Window != steamWindow { + t.Fatal("foreground left Steam before two controls were observed") + } + if pid <= 0 || event.ForegroundMismatch { + continue + } + comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil || strings.TrimSpace(string(comm)) != "steamwebhelper" { + continue + } + if steamWindow == 0 && event.Foreground.Client.Window != 0 { + steamWindow = event.Foreground.Client.Window + } + switch event.Type { + case WindowActivatedEvent: + if event.Source.Role == "frame" { + frames++ + } + case FocusEvent, FocusedStateEvent: + if event.Type == FocusedStateEvent && event.Detail1 == 0 { + continue + } + default: + continue + } + if !event.Source.Focused || !event.Window.Valid() || + event.Source.BusName != event.Window.BusName || + strings.TrimSpace(event.Source.Name) == "" || + !steamNavigationControl(event.Source.Role) { + continue + } + focused[event.Source.ID()] = event.Source.Role + if len(focused) >= 2 { + t.Logf("Steam exposed %d distinct focused controls after %d frame activations", len(focused), frames) + return + } + } + } +} + +func TestNativeThunarNestedFileCellLabel(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_THUNAR") != "1" { + t.Skip("requires an isolated Thunar details view with Alpha and Beta test files") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Alpha", "Beta"} { + child := findNamedReference(t, tree, name) + if !child.Valid() { + t.Fatalf("Thunar did not expose %s", name) + } + row, err := tree.Resolve(child) + if err != nil || row.Role != "table cell" || row.Name != name { + t.Fatalf("Thunar row for %s = %#v, %v", name, row, err) + } + parent, ok, err := tree.Parent(child) + if err != nil || !ok { + t.Fatalf("parent of %s: %v", name, err) + } + snapshot, err := tree.Resolve(parent) + if err != nil || snapshot.Role != "tree table" { + t.Fatalf("Thunar row %s is not the named child of the file view: %#v, %v", name, snapshot, err) + } + } + if err := exec.Command("xdotool", "key", "Down").Run(); err != nil { + t.Fatal(err) + } + for { + select { + case <-ctx.Done(): + t.Fatal("Thunar details navigation did not expose a file row") + case event := <-adapter.Events(): + if event.Source.Role != "table cell" || + (event.Type != SelectionChangedEvent && event.Type != ActiveDescendantEvent) || + (event.Source.Name != "Alpha" && event.Source.Name != "Beta") { + continue + } + if !strings.Contains(event.Source.TableRowText, event.Source.Name) { + t.Fatalf("Thunar file name missing from row %q for %q", event.Source.TableRowText, event.Source.Name) + } + return + } + } +} + +func steamNavigationControl(role string) bool { + switch role { + case "button", "push button", "toggle button", "check box", "link", "menu item", "page tab", "list item": + return true + default: + return false + } +} + +func TestNativeTreeCaptureDefunctAndShutdownInvalidation(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_ATSPI") != "1" { + t.Skip("requires isolated AT-SPI and X11 session") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + textRef := findNamedReference(t, tree, "Review text probe") + numericRef := findNamedReference(t, tree, "Numeric value probe") + numeric, err := tree.Resolve(numericRef) + if err != nil || !numeric.ValueKnown || numeric.CurrentValue != 1.25 || numeric.MinimumIncrement != 0.01 { + t.Fatalf("native numeric value: known=%v value=%v increment=%v err=%v", numeric.ValueKnown, numeric.CurrentValue, numeric.MinimumIncrement, err) + } + assertNativeReviewText(t, tree, textRef, "Café 猫Second line") + if os.Getenv("CTHULHU_TREE_CONTAINER") == "list" { + assert_native_list_position(t, tree, "Persistent control", 1, 7) + assert_native_list_position(t, tree, "Removable control", 2, 7) + } + caretRef := findNamedReference(t, tree, "Caret review probe") + if captured, err := tree.VisibleText(caretRef); err != nil || !captured.CaretKnown || captured.CaretOffset != 6 { + t.Fatalf("native caret: known=%v offset=%d err=%v", captured.CaretKnown, captured.CaretOffset, err) + } + clippedRef := findNamedReference(t, tree, "Clipped review probe") + clippedText, clippedErr := tree.VisibleText(clippedRef) + var clippedContent strings.Builder + for _, r := range clippedText.Ranges { + clippedContent.WriteString(r.Text) + } + if clippedErr != nil || !clippedText.Known || !strings.HasPrefix(clippedContent.String(), "Clipped ") || strings.Contains(clippedContent.String(), "OFFSCREEN") { + t.Fatalf("native clipped text = %q known=%v err=%v", clippedContent.String(), clippedText.Known, clippedErr) + } + protected := findNamedReference(t, tree, "Protected text probe") + if captured, err := tree.VisibleText(protected); err != nil || captured.Known || len(captured.Ranges) != 0 { + t.Fatalf("protected review text was captured: known=%v ranges=%d err=%v", captured.Known, len(captured.Ranges), err) + } + assertNativeBrailleRouting(t, ctx, adapter) + pressTreeProbeKey(t, "t") + waitForSoundGeometry(t, ctx, adapter.Events()) + waitForWindowName(t, ctx, "Cthulhu Tree Probe Text Changed") + for { + _, err := tree.VisibleText(textRef) + if errors.Is(err, ErrStaleText) { + break + } + if err != nil { + t.Fatal(err) + } + select { + case <-ctx.Done(): + t.Fatal("text event did not invalidate review text") + case <-time.After(10 * time.Millisecond): + } + } + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + assertNativeReviewText(t, tree, textRef, "Changed 猫New line") + removable := findNamedReference(t, tree, "Removable control") + if !removable.Valid() { + t.Fatal("native capture did not contain removable control") + } + pressTreeProbeKey(t, "r") + waitForWindowName(t, ctx, "Cthulhu Tree Probe Removed") + waitForStale(t, ctx, tree, removable) + + treeAfterRemoval, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + if treeAfterRemoval != tree { + t.Fatal("recapture replaced the owned tree instance") + } + persistent := findNamedReference(t, tree, "Persistent control") + if !persistent.Valid() { + t.Fatal("recapture did not contain persistent control") + } + if os.Getenv("CTHULHU_TREE_CONTAINER") == "list" { + assert_native_list_position(t, tree, "Persistent control", 1, 6) + } + if os.Getenv("CTHULHU_TREE_CONTAINER") == "box" { + container := findNamedReference(t, tree, "Dynamic controls") + if !container.Valid() { + t.Fatal("dynamic container missing") + } + pressTreeProbeKey(t, "a") + waitForWindowName(t, ctx, "Cthulhu Tree Probe Added") + waitForStale(t, ctx, tree, persistent) + if _, ok, err := tree.FirstChild(container); ok || !errors.Is(err, ErrIncompleteTree) { + t.Fatalf("added children were treated as complete: %v %v", ok, err) + } + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok, err := tree.FirstChild(container); !ok || err != nil { + t.Fatalf("recapture failed to restore container traversal: %v %v", ok, err) + } + if added := findNamedReference(t, tree, "Added control"); !added.Valid() { + t.Fatal("recapture did not contain added control") + } + persistent = findNamedReference(t, tree, "Persistent control") + if !persistent.Valid() { + t.Fatal("recapture lost persistent control") + } + } + if os.Getenv("CTHULHU_TREE_CONTAINER") == "box" { + container := findNamedReference(t, tree, "Dynamic controls") + before, err := tree.Geometry(container) + if err != nil || !before.BoundsKnown || !before.Visible || !before.Showing { + t.Fatalf("native component geometry unavailable: %v %v", before, err) + } + pressTreeProbeKey(t, "m") + waitForWindowName(t, ctx, "Cthulhu Tree Probe Resized") + waitForGeometryInvalidation(t, ctx, tree, container) + if _, err := tree.Resolve(container); err != nil { + t.Fatalf("resize removed object: %v", err) + } + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + after, err := tree.Geometry(container) + if err != nil || !after.BoundsKnown || after.Bounds.Width <= before.Bounds.Width { + t.Fatalf("resize geometry = %v %v; previous = %v", after, err, before) + } + persistent = findNamedReference(t, tree, "Persistent control") + pressTreeProbeKey(t, "h") + waitForWindowName(t, ctx, "Cthulhu Tree Probe Hidden") + waitForGeometryInvalidation(t, ctx, tree, persistent) + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + hidden, err := tree.Geometry(persistent) + if err != nil || !hidden.VisibilityKnown || hidden.Showing || hidden.BoundsKnown { + t.Fatalf("hidden object geometry = %v %v", hidden, err) + } + } + textRef = findNamedReference(t, tree, "Review text probe") + pressTreeProbeKey(t, "v") + waitForWindowName(t, ctx, "Cthulhu Tree Probe Text Hidden") + waitForGeometryInvalidation(t, ctx, tree, textRef) + if _, err := tree.VisibleText(textRef); !errors.Is(err, ErrStaleText) { + t.Fatalf("hidden text did not become stale: %v", err) + } + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + if hidden, err := tree.VisibleText(textRef); err != nil || hidden.Known || len(hidden.Ranges) != 0 { + t.Fatalf("hidden text retained: known=%v ranges=%d err=%v", hidden.Known, len(hidden.Ranges), err) + } + if adapter.TreeInvalidations() == 0 { + t.Fatal("native defunct event did not invalidate cached objects") + } + pressTreeProbeKey(t, "w") + waitForWindowLifecycle(t, ctx, adapter.Events()) + + if err := adapter.Stop(ctx); err != nil { + t.Fatal(err) + } + if _, err := tree.Resolve(persistent); !errors.Is(err, ErrStaleObject) { + t.Fatalf("adapter stop left a live reference: %v", err) + } +} + +func waitForWindowLifecycle(t *testing.T, ctx context.Context, events <-chan Event) { + t.Helper() + var activated ObjectID + deactivated := false + for { + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for window lifecycle: activated=%v deactivated=%v", activated.Valid(), deactivated) + case event := <-events: + switch event.Type { + case WindowActivatedEvent: + if event.Source.Name != "Activated control" { + continue + } + if (event.Source.Role != "button" && event.Source.Role != "push button") || !event.Source.Focused { + t.Fatalf("activation focus snapshot = %+v", event.Source) + } + if !event.Window.Valid() || event.Window.Path == event.Source.Path || event.Window.BusName != event.Source.BusName { + t.Fatalf("activation ownership missing: source=%+v window=%+v origin=%q", event.Source.ID(), event.Window, event.OriginPath) + } + activated = event.Window + case WindowDeactivatedEvent: + if activated.Valid() && event.Window == activated { + deactivated = true + } + case WindowDestroyedEvent: + if activated.Valid() && event.Window == activated { + if !deactivated { + t.Fatal("activated window was destroyed without a deactivation event") + } + return + } + } + } + } +} + +func waitForSoundGeometry(t *testing.T, ctx context.Context, events <-chan Event) { + t.Helper() + for { + select { + case <-ctx.Done(): + t.Fatal("timed out waiting for captured sound geometry") + case event := <-events: + if event.Source.SoundGeometry.ObjectKnown && event.Source.SoundGeometry.ReferenceKnown { + object := event.Source.SoundGeometry.Object + reference := event.Source.SoundGeometry.Reference + if object.Valid() && reference.Valid() { + return + } + } + } + } +} + +func assert_native_list_position(t *testing.T, tree *AccessibleTree, name string, position, size int) { + t.Helper() + label := findNamedReference(t, tree, name) + row, ok, err := tree.Parent(label) + if err != nil || !ok { + t.Fatalf("%s row missing: %v", name, err) + } + snapshot, err := tree.Resolve(row) + if err != nil || snapshot.PositionInSet != position || snapshot.SetSize != size { + t.Fatalf("%s (%s) position=%d size=%d, want %d of %d: %v", name, snapshot.Role, snapshot.PositionInSet, snapshot.SetSize, position, size, err) + } +} + +func findNamedReference(t *testing.T, tree *AccessibleTree, name string) ObjectReference { + t.Helper() + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + pending := []ObjectReference{root} + for len(pending) != 0 { + last := len(pending) - 1 + current := pending[last] + pending = pending[:last] + snapshot, resolveErr := tree.Resolve(current) + if resolveErr != nil { + t.Fatal(resolveErr) + } + if snapshot.Name == name { + return current + } + child, ok, childErr := tree.FirstChild(current) + if childErr != nil { + t.Fatal(childErr) + } + for ok { + pending = append(pending, child) + child, ok, childErr = tree.NextSibling(child) + if childErr != nil { + t.Fatal(childErr) + } + } + } + return ObjectReference{} +} + +func pressTreeProbeKey(t *testing.T, key string) { + t.Helper() + window := os.Getenv("CTHULHU_TREE_WINDOW") + if window == "" { + t.Fatal("CTHULHU_TREE_WINDOW is empty") + } + if err := exec.Command("xdotool", "windowfocus", window, "key", key).Run(); err != nil { + t.Fatalf("press %s in tree probe: %v", key, err) + } +} + +func waitForStale(t *testing.T, ctx context.Context, tree *AccessibleTree, reference ObjectReference) { + t.Helper() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := tree.Resolve(reference); errors.Is(err, ErrStaleObject) { + return + } + select { + case <-ctx.Done(): + t.Fatalf("reference did not become stale: %v", ctx.Err()) + case <-ticker.C: + } + } +} + +func waitForWindowName(t *testing.T, ctx context.Context, name string) { + t.Helper() + window := os.Getenv("CTHULHU_TREE_WINDOW") + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + output, err := exec.Command("xdotool", "getwindowname", window).Output() + if err == nil && string(output) == name+"\n" { + return + } + select { + case <-ctx.Done(): + t.Fatalf("GTK mutation %q was not observed: %v", name, ctx.Err()) + case <-ticker.C: + } + } +} + +func waitForGeometryInvalidation(t *testing.T, ctx context.Context, tree *AccessibleTree, reference ObjectReference) { + t.Helper() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + _, err := tree.Geometry(reference) + if errors.Is(err, ErrStaleGeometry) { + return + } + if err != nil { + t.Fatalf("object identity lost during geometry change: %v", err) + } + select { + case <-ctx.Done(): + t.Fatalf("geometry did not become stale: %v", ctx.Err()) + case <-ticker.C: + } + } +} + +func assertNativeReviewText(t *testing.T, tree *AccessibleTree, ref ObjectReference, want string) { + t.Helper() + captured, err := tree.VisibleText(ref) + if err != nil || !captured.Known { + for current := ref; current.Valid(); { + snap, _ := tree.Resolve(current) + bounds, boundsErr := tree.Geometry(current) + t.Logf("review ancestor role=%q geometry=%+v err=%v", snap.Role, bounds, boundsErr) + parent, ok, _ := tree.Parent(current) + if !ok { + break + } + current = parent + } + t.Fatalf("native visible text unavailable: known=%v limited=%v err=%v", captured.Known, captured.Limited, err) + } + var content strings.Builder + for _, r := range captured.Ranges { + content.WriteString(r.Text) + } + if content.String() != want { + t.Fatalf("native text = %q, want %q", content.String(), want) + } + geometry, err := tree.Geometry(ref) + if err != nil || !captured.valid(geometry) { + t.Fatalf("invalid native text geometry: %v", err) + } +} + +func TestNativeGUITableScreenLockRecovery(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_GUI_TABLE") != "1" { + t.Skip("requires isolated GTK table fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + key := func(name string) { + t.Helper() + if err := exec.CommandContext(ctx, "xdotool", "key", name).Run(); err != nil { + t.Fatal(err) + } + } + key("Down") + for { + select { + case event := <-adapter.Events(): + if event.Type == SelectionChangedEvent && event.Source.Name == "Beta" { + goto locked + } + case <-ctx.Done(): + t.Fatal("initial table focus did not arrive") + } + } +locked: + adapter.SetSecureInputActive(true) + if _, err := adapter.CaptureDesktop(ctx); err == nil { + t.Fatal("desktop capture was allowed while locked") + } + key("Up") + select { + case <-adapter.Events(): + t.Fatal("AT-SPI content escaped secure mode") + case <-time.After(100 * time.Millisecond): + } + adapter.SetSecureInputActive(false) + // The provider retains focus and need not emit any fresh focus event. Unlock + // must recover the current focused object without requiring another key. + for { + select { + case event := <-adapter.Events(): + if event.Type == WindowActivatedEvent { + if event.Source.Name != "Alpha" { + t.Fatalf("unlock focus = %q, want Alpha", event.Source.Name) + } + return + } + case <-ctx.Done(): + t.Fatal("unlock did not recover retained GTK focus") + } + } +} diff --git a/internal/atspi/adapter_ocr_native.go b/internal/atspi/adapter_ocr_native.go new file mode 100644 index 0000000..c312f2c --- /dev/null +++ b/internal/atspi/adapter_ocr_native.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "time" +) + +// CaptureOCRGeometry runs only a narrow target/ancestor query on the GLib +// owner. Call it from a worker, not the event loop. A zone remains provider +// coordinates and is validated/clipped to the fresh object's bounds. +func (adapter *Adapter) CaptureOCRGeometry(ctx context.Context, object ObjectID, zone *Rectangle) (OCRGeometry, error) { + if !object.Valid() { + return OCRGeometry{}, ErrObjectNotFound + } + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + var ownedZone *Rectangle + if zone != nil { + copy := *zone + ownedZone = © + } + result, err := adapter.documentOperation(ctx, treeCaptureRequest{ocrGeometry: true, reviewFocus: object, ocrZone: ownedZone}) + return result.ocr, err +} + +func (adapter *Adapter) nativeOCRGeometry(ctx context.Context, object ObjectID, zone *Rectangle) (OCRGeometry, error) { + var source *C.AtspiAccessible + for _, item := range adapter.nativeFocus { + if item.id == object { + source = item.source + break + } + } + if source == nil { + source = adapter.nativeReview[object] + } + if source == nil { + source = adapter.nativeDocument[object] + } + if source == nil { + return OCRGeometry{}, ErrObjectNotFound + } + adapter.refreshForeground() + before := adapter.Foreground() + if !before.Client.Known || before.Client.Window == 0 || before.Client.PID <= 0 { + return OCRGeometry{}, ErrOCRGeometry + } + // Reject dead source/ancestor content without querying text. OCR reads only + // visible pixels, so a password role does not prohibit capture geometry. + current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) + valid := false + seen := make(map[ObjectID]bool) + for depth := 0; current != nil && depth < 128 && ctx.Err() == nil; depth++ { + id := nativeObjectID(current) + C.atspi_accessible_clear_cache_single(current) + states := C.atspi_accessible_get_state_set(current) + safe := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0 + if states != nil { + C.g_object_unref(C.gpointer(states)) + } + role := C.atspi_accessible_get_role(current, nil) + if !safe || !id.Valid() || seen[id] || role == C.ATSPI_ROLE_INVALID { + C.g_object_unref(C.gpointer(current)) + return OCRGeometry{}, ErrOCRGeometry + } + seen[id] = true + if isWindowRole(role) { + valid = true + C.g_object_unref(C.gpointer(current)) + current = nil + break + } + parent := C.atspi_accessible_get_parent(current, nil) + C.g_object_unref(C.gpointer(current)) + current = parent + } + if current != nil { + C.g_object_unref(C.gpointer(current)) + } + if err := ctx.Err(); err != nil { + return OCRGeometry{}, err + } + if !valid { + return OCRGeometry{}, ErrOCRGeometry + } + window := C.cthulhu_window_ancestor(source) + if window == nil { + return OCRGeometry{}, ErrOCRGeometry + } + defer C.g_object_unref(C.gpointer(window)) + if int(C.atspi_accessible_get_process_id(window, nil)) != before.Client.PID || (adapter.foregroundWindow.Valid() && adapter.foregroundWindow != nativeObjectID(window)) { + return OCRGeometry{}, ErrOCRGeometry + } + states := C.atspi_accessible_get_state_set(window) + active := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_ACTIVE) != 0 && C.atspi_state_set_contains(states, C.ATSPI_STATE_ICONIFIED) == 0 + if states != nil { + C.g_object_unref(C.gpointer(states)) + } + if !active { + return OCRGeometry{}, ErrOCRGeometry + } + geometry, err := geometryFromNative(ctx, source) + if err != nil { + return OCRGeometry{}, err + } + windowGeometry, err := geometryFromNative(ctx, window) + if err != nil { + return OCRGeometry{}, err + } + if !geometry.BoundsKnown || !windowGeometry.BoundsKnown { + return OCRGeometry{}, ErrOCRGeometry + } + bounds := geometry.Bounds + if zone != nil { + clipped, ok := zone.Intersection(bounds) + // A stale zone must not move or expand its captured area implicitly. + if !ok || clipped != *zone { + return OCRGeometry{}, ErrStaleGeometry + } + bounds = clipped + } + screen, err := ocr_screen_bounds(bounds, windowGeometry.Bounds, before.Client) + adapter.refreshForeground() + if err := ctx.Err(); err != nil { + return OCRGeometry{}, err + } + if adapter.Foreground() != before { + return OCRGeometry{}, ErrStaleGeometry + } + if err != nil { + return OCRGeometry{}, err + } + return OCRGeometry{Object: object, ProviderBounds: bounds, WindowBounds: windowGeometry.Bounds, ScreenBounds: screen, Foreground: before}, nil +} + +func isWindowRole(role C.AtspiRole) bool { + return role == C.ATSPI_ROLE_FRAME || role == C.ATSPI_ROLE_DIALOG || role == C.ATSPI_ROLE_WINDOW +} diff --git a/internal/atspi/adapter_position.c b/internal/atspi/adapter_position.c new file mode 100644 index 0000000..40be846 --- /dev/null +++ b/internal/atspi/adapter_position.c @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "adapter_native.h" + +gboolean cthulhu_has_set_position(const gchar *role) { + return g_strcmp0(role, "list item") == 0 || g_strcmp0(role, "tree item") == 0 || + g_strcmp0(role, "menu item") == 0 || g_strcmp0(role, "check menu item") == 0 || + g_strcmp0(role, "radio menu item") == 0 || g_strcmp0(role, "page tab") == 0 || + g_strcmp0(role, "radio button") == 0; +} + +static gint cthulhu_positive_integer(const gchar *value) { + if (value == NULL || *value < '0' || *value > '9') return 0; + gchar *end = NULL; + gint64 number = g_ascii_strtoll(value, &end, 10); + if (*end != '\0' || number <= 0 || number > G_MAXINT) return 0; + return (gint)number; +} + +static gboolean cthulhu_menu_item(AtspiRole role) { + return role == ATSPI_ROLE_MENU_ITEM || role == ATSPI_ROLE_CHECK_MENU_ITEM || + role == ATSPI_ROLE_RADIO_MENU_ITEM; +} + +static gboolean cthulhu_group_member(AtspiRelation *relation, AtspiAccessible *child) { + for (gint index = 0; index < atspi_relation_get_n_targets(relation); index++) { + AtspiAccessible *target = atspi_relation_get_target(relation, index); + gboolean matches = target != NULL && + g_strcmp0(cthulhu_accessible_bus_name(target), cthulhu_accessible_bus_name(child)) == 0 && + g_strcmp0(cthulhu_accessible_path(target), cthulhu_accessible_path(child)) == 0; + if (target != NULL) g_object_unref(target); + if (matches) return TRUE; + } + return FALSE; +} + +void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attributes, + cthulhu_snapshot *snapshot) { + const gchar *position = attributes == NULL ? NULL : g_hash_table_lookup(attributes, "posinset"); + const gchar *size = attributes == NULL ? NULL : g_hash_table_lookup(attributes, "setsize"); + // Explicit provider values describe virtualized and hierarchical sets. + // Partial, unknown (-1), or invalid values must not become sibling guesses. + if (position != NULL || size != NULL) { + gint parsedPosition = cthulhu_positive_integer(position); + gint parsedSize = cthulhu_positive_integer(size); + if (parsedPosition > 0 && parsedPosition <= parsedSize) { + snapshot->positionInSet = parsedPosition; + snapshot->setSize = parsedSize; + } + return; + } + GError *error = NULL; + AtspiAccessible *parent = atspi_accessible_get_parent(source, &error); + if (error != NULL || parent == NULL) { + g_clear_error(&error); + if (parent != NULL) g_object_unref(parent); + return; + } + AtspiRole sourceRole = atspi_accessible_get_role(source, &error); + GArray *relations = NULL; + AtspiRelation *memberRelation = NULL; + if (error != NULL) goto cleanup; + if (sourceRole == ATSPI_ROLE_RADIO_BUTTON) { + relations = atspi_accessible_get_relation_set(source, &error); + if (error != NULL) goto cleanup; + for (guint index = 0; relations != NULL && index < relations->len; index++) { + AtspiRelation *relation = g_array_index(relations, AtspiRelation *, index); + if (atspi_relation_get_relation_type(relation) == ATSPI_RELATION_MEMBER_OF) { + memberRelation = relation; + if (atspi_relation_get_n_targets(relation) > 128) goto cleanup; + break; + } + } + } + AtspiRole parentRole = atspi_accessible_get_role(parent, &error); + if (error != NULL) goto cleanup; + // A flattened tree does not reliably describe logical sibling sets. + if (sourceRole == ATSPI_ROLE_TREE_ITEM) goto cleanup; + if (sourceRole == ATSPI_ROLE_LIST_ITEM && parentRole != ATSPI_ROLE_LIST && parentRole != ATSPI_ROLE_LIST_BOX) + goto cleanup; + if (sourceRole == ATSPI_ROLE_PAGE_TAB && parentRole != ATSPI_ROLE_PAGE_TAB_LIST) goto cleanup; + if (cthulhu_menu_item(sourceRole) && parentRole != ATSPI_ROLE_MENU && parentRole != ATSPI_ROLE_MENU_BAR) + goto cleanup; + gint count = atspi_accessible_get_child_count(parent, &error); + if (error != NULL) goto cleanup; + if (count <= 0) goto cleanup; + gint index = atspi_accessible_get_index_in_parent(source, &error); + if (error != NULL || index < 0 || index >= count) goto cleanup; + // Lists and tab lists expose their items in child order, including large + // sets. This uses a fixed number of queries rather than a sibling walk. + if (sourceRole == ATSPI_ROLE_LIST_ITEM || sourceRole == ATSPI_ROLE_PAGE_TAB) { + snapshot->positionInSet = index + 1; + snapshot->setSize = count; + goto cleanup; + } + // Menus contain separators; radio containers can contain labels and + // unrelated controls. Count only item peers, with bounded native work. + if (count > 128) goto cleanup; + gint itemPosition = 0, setSize = 0; + for (gint childIndex = 0; childIndex < count; childIndex++) { + AtspiAccessible *child = atspi_accessible_get_child_at_index(parent, childIndex, &error); + if (error != NULL || child == NULL) { + if (child != NULL) g_object_unref(child); + goto cleanup; + } + AtspiRole childRole = atspi_accessible_get_role(child, &error); + gboolean member = memberRelation == NULL || cthulhu_group_member(memberRelation, child); + g_object_unref(child); + if (error != NULL) goto cleanup; + if (!member) continue; + if (childRole != sourceRole && !(cthulhu_menu_item(sourceRole) && cthulhu_menu_item(childRole))) continue; + setSize++; + if (childIndex == index) itemPosition = setSize; + } + snapshot->positionInSet = itemPosition; + snapshot->setSize = setSize; +cleanup: + if (relations != NULL) { + for (guint index = 0; index < relations->len; index++) + g_object_unref(g_array_index(relations, AtspiRelation *, index)); + g_array_unref(relations); + } + g_clear_error(&error); + g_object_unref(parent); +} diff --git a/internal/atspi/adapter_references_native.go b/internal/atspi/adapter_references_native.go new file mode 100644 index 0000000..9e752e9 --- /dev/null +++ b/internal/atspi/adapter_references_native.go @@ -0,0 +1,383 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "time" +) + +func (adapter *Adapter) captureDesktop(ctx context.Context, reviewFocus ObjectID, document, documentText bool) (*AccessibleTree, error) { + if document { + root, err := adapter.nativeDocumentRoot(ctx, reviewFocus) + adapter.treeMutex.Lock() + previousTree := adapter.documentTree + adapter.treeMutex.Unlock() + if (errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrStaleObject)) && previousTree != nil && len(adapter.nativeFocus) != 0 { + // Dynamic pages can remove the browse cursor without sending a new + // focus event. Recover only from the newest retained native focus in + // the same document. A chrome or other-tab focus is not a fallback. + currentRoot, rootErr := previousTree.Root() + for i := len(adapter.nativeFocus) - 1; rootErr == nil && i >= 0; i-- { + latest := adapter.nativeFocus[i] + if latest.id.BusName != reviewFocus.BusName { + break + } + C.atspi_accessible_clear_cache_single(latest.source) + states := C.atspi_accessible_get_state_set(latest.source) + if states == nil { + continue + } + dead := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0 + C.g_object_unref(C.gpointer(states)) + if dead { + continue + } + // A removed browse target can retain a non-defunct proxy after + // Chromium has dropped its accessible object. Its role is then + // invalid; try the preceding real focus instead. + if C.atspi_accessible_get_role(latest.source, nil) == C.ATSPI_ROLE_INVALID { + continue + } + candidate, candidateErr := adapter.nativeDocumentRoot(ctx, latest.id) + if candidateErr != nil { + break + } + if nativeObjectID(candidate) == currentRoot.ID() { + root, err = candidate, nil + } else { + C.g_object_unref(C.gpointer(candidate)) + } + break + } + } + if err != nil { + return nil, err + } + cache := nativeTreeCache{} + if !documentText && previousTree != nil { + cache.nodes = previousTree.nativeRefresh(nativeObjectID(root)) + cache.sources = adapter.nativeDocument + } + // Retain this bounded capture for narrow OCR geometry queries too. + retained := make(map[ObjectID]*C.AtspiAccessible) + rootID, nodes, err := captureNativeTree(ctx, root, "", true, documentText, adapter.captureSound.Load(), retained, cache) + var tree *AccessibleTree + if err == nil { + tree, err = adapter.installCapturedTree(rootID, nodes, true, documentText) + } + if err != nil { + for _, source := range retained { + C.g_object_unref(C.gpointer(source)) + } + return nil, err + } + adapter.clearNativeDocument() + adapter.nativeDocument = retained + return tree, nil + } + var rootSource *C.AtspiAccessible + if reviewFocus.Valid() { + // A flat-review command only needs its owning window. Starting from + // the desktop walks every window in the browser process, including + // unrelated tabs and detached windows, before selecting this one. + for _, item := range adapter.nativeFocus { + if item.id == reviewFocus { + rootSource = C.cthulhu_window_ancestor(item.source) + break + } + } + } + if rootSource == nil { + rootSource = C.atspi_get_desktop(0) + } + if rootSource == nil { + return nil, errors.New("capture AT-SPI review window: root is unavailable") + } + retained := make(map[ObjectID]*C.AtspiAccessible) + cache := nativeTreeCache{} + if reviewFocus.BusName != "" { + if adapter.nativeReviewBus == reviewFocus.BusName { + adapter.treeMutex.Lock() + previousTree := adapter.tree + adapter.treeMutex.Unlock() + if previousTree != nil { + cache.nodes = previousTree.nativeRefresh(nativeObjectID(rootSource)) + cache.sources = adapter.nativeReview + } + } + } + root, nodes, err := captureNativeTree(ctx, rootSource, reviewFocus.BusName, document, documentText, adapter.captureSound.Load(), retained, cache) + if err != nil { + for _, source := range retained { + C.g_object_unref(C.gpointer(source)) + } + return nil, err + } + tree, err := adapter.installCapturedTree(root, nodes, document, documentText) + if err != nil { + for _, source := range retained { + C.g_object_unref(C.gpointer(source)) + } + return nil, err + } + adapter.clearNativeReview() + adapter.nativeReview = retained + adapter.nativeReviewBus = reviewFocus.BusName + return tree, nil +} + +// Remember a bounded set of event sources so document capture can start at +// native focus, without rediscovering it through cached browser chrome. All +// references are used and released on the GLib thread, including at shutdown. +func (adapter *Adapter) rememberNativeFocus(source *C.AtspiAccessible, id ObjectID) { + if !id.Valid() { + return + } + for i, item := range adapter.nativeFocus { + if item.id == id { + C.g_object_unref(C.gpointer(item.source)) + adapter.nativeFocus = append(adapter.nativeFocus[:i], adapter.nativeFocus[i+1:]...) + break + } + } + if len(adapter.nativeFocus) == 16 { + C.g_object_unref(C.gpointer(adapter.nativeFocus[0].source)) + adapter.nativeFocus = adapter.nativeFocus[1:] + } + owned := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) + adapter.nativeFocus = append(adapter.nativeFocus, nativeFocusReference{id, owned}) +} + +// Keep an exact document-to-frame association across Chromium tab switches. +// Some returned tabs temporarily lose their AT-SPI parent chain, although +// their document identity and the already correlated frame remain live. +func (adapter *Adapter) rememberDocumentWindow(document ObjectID, window *C.AtspiAccessible) { + if !document.Valid() || window == nil { + return + } + if adapter.documentWindows == nil { + adapter.documentWindows = make(map[ObjectID]nativeDocumentWindow) + } + if old, ok := adapter.documentWindows[document]; ok { + C.g_object_unref(C.gpointer(old.source)) + } else { + if len(adapter.documentWindowIDs) == 32 { + oldest := adapter.documentWindowIDs[0] + C.g_object_unref(C.gpointer(adapter.documentWindows[oldest].source)) + delete(adapter.documentWindows, oldest) + adapter.documentWindowIDs = adapter.documentWindowIDs[1:] + } + adapter.documentWindowIDs = append(adapter.documentWindowIDs, document) + } + adapter.documentWindows[document] = nativeDocumentWindow{ + id: nativeObjectID(window), source: (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(window))), + } +} + +func (adapter *Adapter) forgetDocumentWindow(document ObjectID) { + known, ok := adapter.documentWindows[document] + if !ok { + return + } + C.g_object_unref(C.gpointer(known.source)) + delete(adapter.documentWindows, document) + for i, id := range adapter.documentWindowIDs { + if id == document { + adapter.documentWindowIDs = append(adapter.documentWindowIDs[:i], adapter.documentWindowIDs[i+1:]...) + break + } + } + if adapter.activeDocument == document { + adapter.activeDocument = ObjectID{} + } +} + +func (adapter *Adapter) forgetWindowDocuments(window ObjectID) { + for _, id := range append([]ObjectID(nil), adapter.documentWindowIDs...) { + if adapter.documentWindows[id].id == window { + adapter.forgetDocumentWindow(id) + } + } + if adapter.selectedTabWindow == window { + adapter.selectedTabWindow = ObjectID{} + } +} + +func (adapter *Adapter) detachedDocumentWindow(source *C.AtspiAccessible, eventType string, detail int) (*C.AtspiAccessible, ObjectID) { + if adapter.selectedTabWindow == (ObjectID{}) && !adapter.activeDocument.Valid() { + return nil, ObjectID{} + } + var document *C.AtspiAccessible + if _, known := adapter.documentWindows[nativeObjectID(source)]; known { + document = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) + } else { + document = C.cthulhu_document_ancestor(source) + } + if document == nil { + return nil, ObjectID{} + } + id := nativeObjectID(document) + states := C.atspi_accessible_get_state_set(document) + showing := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_SHOWING) != 0 && + C.atspi_state_set_contains(states, C.ATSPI_STATE_VISIBLE) != 0 && + C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0 + if states != nil { + C.g_object_unref(C.gpointer(states)) + } + C.g_object_unref(C.gpointer(document)) + known, ok := adapter.documentWindows[id] + if !showing || !ok || !restoredDocumentWindowAllowed(id, adapter.activeDocument, + adapter.selectedTabWindow, known.id, eventType, detail) { + return nil, ObjectID{} + } + frameStates := C.atspi_accessible_get_state_set(known.source) + frameActive := frameStates != nil && C.atspi_state_set_contains(frameStates, C.ATSPI_STATE_ACTIVE) != 0 && + C.atspi_state_set_contains(frameStates, C.ATSPI_STATE_SHOWING) != 0 && + C.atspi_state_set_contains(frameStates, C.ATSPI_STATE_DEFUNCT) == 0 + if frameStates != nil { + C.g_object_unref(C.gpointer(frameStates)) + } + if !frameActive { + return nil, ObjectID{} + } + return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(known.source))), id +} + +func restoredDocumentWindowAllowed(document, active, selectedWindow, knownWindow ObjectID, eventType string, detail int) bool { + if !document.Valid() || !knownWindow.Valid() { + return false + } + if eventType == FocusedStateEvent && detail == 0 { + return false + } + if document == active { + return true + } + return selectedWindow == knownWindow && eventType == FocusedStateEvent && detail != 0 +} + +// Called only by the GLib owner at load completion. Chromium can finish an +// earlier load after the user has returned to chrome. Window activation alone +// cannot distinguish that from a load which should acquire document focus. +func (adapter *Adapter) focusedChrome(document ObjectID) ObjectID { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + for i := len(adapter.nativeFocus) - 1; i >= 0 && ctx.Err() == nil; i-- { + item := adapter.nativeFocus[i] + if item.id.BusName != document.BusName || item.id == document { + continue + } + C.atspi_accessible_clear_cache_single(item.source) + states := C.atspi_accessible_get_state_set(item.source) + if states == nil { + continue + } + focused := C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 + C.g_object_unref(C.gpointer(states)) + if !focused { + continue + } + root, err := adapter.nativeDocumentRoot(ctx, item.id) + if root != nil { + C.g_object_unref(C.gpointer(root)) + } + if errors.Is(err, ErrNotInDocument) { + return item.id + } + } + return ObjectID{} +} + +// Return an owned reference to the outermost document containing focus. +// Parent traversal is bounded and never scans other windows or tabs. +func (adapter *Adapter) nativeDocumentRoot(ctx context.Context, focus ObjectID) (*C.AtspiAccessible, error) { + var source *C.AtspiAccessible + for _, item := range adapter.nativeFocus { + if item.id == focus { + source = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(item.source))) + break + } + } + if source == nil { + return nil, fmt.Errorf("%w: document focus bus %q path %q", ErrObjectNotFound, focus.BusName, focus.Path) + } + C.atspi_accessible_clear_cache_single(source) + states := C.atspi_accessible_get_state_set(source) + if states != nil { + dead := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0 + C.g_object_unref(C.gpointer(states)) + if dead { + C.g_object_unref(C.gpointer(source)) + return nil, ErrStaleObject + } + } + var document *C.AtspiAccessible + defer func() { + if source != nil { + C.g_object_unref(C.gpointer(source)) + } + if document != nil { + C.g_object_unref(C.gpointer(document)) + } + }() + seen := make(map[ObjectID]bool) + for depth := 0; source != nil; depth++ { + if err := ctx.Err(); err != nil { + return nil, err + } + id := nativeObjectID(source) + if depth >= 256 || seen[id] { + return nil, ErrInvalidTree + } + seen[id] = true + C.atspi_accessible_clear_cache_single(source) + var nativeErr *C.GError + role := C.atspi_accessible_get_role(source, &nativeErr) + if nativeErr != nil { + err := nativeError("query document ancestor role", nativeErr) + C.g_error_free(nativeErr) + return nil, err + } + if role == C.ATSPI_ROLE_DOCUMENT_WEB || role == C.ATSPI_ROLE_DOCUMENT_FRAME || role == C.ATSPI_ROLE_EMBEDDED { + if document != nil { + C.g_object_unref(C.gpointer(document)) + } + document = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) + } + if role == C.ATSPI_ROLE_APPLICATION { + break + } + parent := C.atspi_accessible_get_parent(source, &nativeErr) + if nativeErr != nil { + if parent != nil { + C.g_object_unref(C.gpointer(parent)) + } + err := nativeError("query document ancestor", nativeErr) + C.g_error_free(nativeErr) + return nil, err + } + C.g_object_unref(C.gpointer(source)) + source = parent + } + if document == nil { + return nil, ErrNotInDocument + } + root := document + document = nil + return root, nil +} + +// geometryFromNative runs only during owned-tree capture on the GLib thread. +// All interface/state/rectangle references are released here; only scalar +// values cross the boundary. Unsupported or failed component queries leave +// bounds unknown, while cancellation aborts capture. diff --git a/internal/atspi/adapter_secure_native.go b/internal/atspi/adapter_secure_native.go new file mode 100644 index 0000000..306b88f --- /dev/null +++ b/internal/atspi/adapter_secure_native.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" +) + +func (adapter *Adapter) SetSecureInputActive(active bool) { + adapter.secureMutex.Lock() + defer adapter.secureMutex.Unlock() + if adapter.secureCancel != nil { + adapter.secureCancel() + } + adapter.secureContext, adapter.secureCancel = context.WithCancel(context.Background()) + if active { + adapter.secureCancel() + } + adapter.secure.Store(active) + adapter.secureEpoch.Add(1) + adapter.secureReset.Store(true) + if !active { + adapter.secureResync.Store(true) + } + for { + select { + case <-adapter.events: + default: + return + } + } +} + +func (adapter *Adapter) applySecureReset() { + if !adapter.secureReset.Swap(false) { + return + } + adapter.invalidateTree() + adapter.treeMutex.Lock() + adapter.tree, adapter.documentTree, adapter.documentTextTree = nil, nil, nil + adapter.treeMutex.Unlock() + adapter.clearNativeDocument() + adapter.clearNativeReview() + adapter.clearMouseApplication() + for _, focus := range adapter.nativeFocus { + C.g_object_unref(C.gpointer(focus.source)) + } + adapter.nativeFocus = nil + for id := range adapter.documentWindows { + adapter.forgetDocumentWindow(id) + } + adapter.activeDocument, adapter.selectedTabWindow = ObjectID{}, ObjectID{} + adapter.foregroundWindow, adapter.promptRefreshWindow = ObjectID{}, ObjectID{} + adapter.promptScannedWindow = ObjectID{} + clear(adapter.navigationEcho) +} + +// Lock transitions cancel captures already in progress as well as later ones. +func (adapter *Adapter) secureRequestContext(ctx context.Context) (context.Context, context.CancelFunc) { + adapter.secureMutex.Lock() + defer adapter.secureMutex.Unlock() + if adapter.secureContext == nil { + adapter.secureContext, adapter.secureCancel = context.WithCancel(context.Background()) + } + requestContext, cancel := context.WithCancel(ctx) + if adapter.secure.Load() { + cancel() + } + stop := context.AfterFunc(adapter.secureContext, cancel) + return requestContext, func() { stop(); cancel() } +} + +// Serialize final delivery with lock queue retirement, including callbacks +// that began snapshotting before the lock name appeared. +func (adapter *Adapter) enqueueSecureEvent(event Event, epoch uint64) { + adapter.secureMutex.Lock() + defer adapter.secureMutex.Unlock() + if adapter.secure.Load() || epoch != adapter.secureEpoch.Load() { + return + } + select { + case adapter.events <- event: + default: + adapter.overflow.Add(1) + } +} diff --git a/internal/atspi/adapter_snapshot_native.go b/internal/atspi/adapter_snapshot_native.go new file mode 100644 index 0000000..a85f2aa --- /dev/null +++ b/internal/atspi/adapter_snapshot_native.go @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "fmt" + "strings" + "time" +) + +func nativeError(operation string, nativeError *C.GError) error { + if nativeError == nil { + return fmt.Errorf("%s failed", operation) + } + return fmt.Errorf("%s: %s", operation, C.GoString(nativeError.message)) +} + +func snapshotFromNative(source *C.AtspiAccessible) Snapshot { + return snapshotFromNativeWithCaret(source, true, false, false, true) +} + +func snapshotFromNativeWithCaret(source *C.AtspiAccessible, text, caretCharacter, documentMetadata, soundGeometry bool, captureAttributes ...bool) Snapshot { + return snapshotFromNativeWithOptions(source, text, caretCharacter, documentMetadata, soundGeometry, true, captureAttributes...) +} + +func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharacter, documentMetadata, soundGeometry, applicationName bool, captureAttributes ...bool) Snapshot { + captureText := C.gboolean(0) + if text { + captureText = 1 + } + captureCaretCharacter := C.gboolean(0) + if caretCharacter { + captureCaretCharacter = 1 + } + captureDocumentMetadata := C.gboolean(0) + if documentMetadata { + captureDocumentMetadata = 1 + } + captureSoundGeometry := C.gboolean(0) + if soundGeometry { + captureSoundGeometry = 1 + } + captureApplicationName := C.gboolean(0) + if applicationName { + captureApplicationName = 1 + } + captured := C.cthulhu_capture_snapshot(source, captureText, captureCaretCharacter, captureDocumentMetadata, captureSoundGeometry, captureApplicationName) + textContent := C.GoString(captured.text) + snapshot := NewSnapshotWithCaretCharacter( + C.GoString(captured.path), + C.GoString(captured.name), + C.GoString(captured.role), + C.GoString(captured.description), + textContent, + C.GoString(captured.caretCharacter), + ) + snapshot.TextCaretKnown = captured.text != nil && captured.textStartOffset >= 0 + if text && snapshot.Role != "password text" { + switch snapshot.Role { + case "button", "push button", "check box", "radio button", "toggle button", "menu item", "check menu item", "radio menu item": + snapshot.Mnemonic = native_mnemonic(source) + } + } + if snapshot.TextCaretKnown { + captureBrailleLine(&snapshot, textContent, C.GoString(captured.textBeforeCaret), int(captured.textStartOffset)) + if text := C.atspi_accessible_get_text(source); text != nil { + snapshot.TextAttributeRun = native_text_attribute_run(text, snapshot.BrailleCaret) + snapshot.Misspelled = TextAttributesMisspelled(snapshot.TextAttributeRun.Values) + if len(captureAttributes) != 0 && captureAttributes[0] { + start := snapshot.BrailleLineStart + snapshot.TextAttributeRuns = native_text_attribute_runs(text, start, start+len([]rune(snapshot.BrailleLine)), snapshot.TextAttributeRun) + } + if C.atspi_text_get_n_selections(text, nil) > 0 { + if selection := C.atspi_text_get_selection(text, 0, nil); selection != nil { + snapshot.SelectionStart, snapshot.SelectionEnd = int(selection.start_offset), int(selection.end_offset) + if snapshot.Role != "password text" && snapshot.SelectionEnd > snapshot.SelectionStart { + end := min(snapshot.SelectionEnd, snapshot.SelectionStart+maxSnapshotFieldLength) + selected := C.atspi_text_get_text(text, C.gint(snapshot.SelectionStart), C.gint(end), nil) + snapshot.SelectionText = strings.ToValidUTF8(C.GoString(selected), "�") + C.g_free(C.gpointer(selected)) + } + C.g_free(C.gpointer(selection)) + } + } + C.g_object_unref(C.gpointer(text)) + } + } + if caretCharacter && captured.editable == 0 && snapshot.Role != "terminal" && strings.ContainsRune(textContent, '\ufffc') { + budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: time.Now().Add(100 * time.Millisecond)} + // Hypertext offsets refer to raw characters, before whitespace folding. + snapshot.Text = sanitizeSnapshotField(expandNativeEmbeddedText(source, textContent, int(captured.textStartOffset), 0, &budget)) + } + snapshot.BusName = sanitizeSnapshotField(C.GoString(captured.busName)) + if snapshot.Role == "radio button" { + group := C.cthulhu_radio_group_name(source) + snapshot.RadioGroupName = sanitizeSnapshotField(C.GoString(group)) + C.g_free(C.gpointer(group)) + } + snapshot.ApplicationName = sanitizeSnapshotField(C.GoString(captured.applicationName)) + snapshot.Visible = captured.visible != 0 + snapshot.Showing = captured.showing != 0 + snapshot.StatesKnown = captured.statesKnown != 0 + snapshot.Active = captured.active != 0 + snapshot.Autocomplete = captured.autocomplete != 0 && snapshot.Role == "entry" + snapshot.Focusable = captured.focusable != 0 + snapshot.Focused = captured.focused != 0 + snapshot.HasPopup = captured.hasPopup != 0 + snapshot.Checked = captured.checked != 0 + snapshot.Defunct = captured.defunct != 0 + snapshot.Editable = captured.editable != 0 + snapshot.Expandable = captured.expandable != 0 + snapshot.Expanded = captured.expanded != 0 + snapshot.Indeterminate = captured.indeterminate != 0 + snapshot.Invalid = captured.invalid != 0 + snapshot.Multiselectable = captured.multiselectable != 0 + snapshot.ReadOnly = captured.readOnly != 0 + snapshot.Required = captured.required != 0 + snapshot.Selected = captured.selected != 0 + snapshot.Sensitive = captured.sensitive != 0 + snapshot.SensitiveKnown = captured.sensitiveKnown != 0 + snapshot.ValueKnown = captured.valueKnown != 0 + snapshot.Visited = captured.visited != 0 + snapshot.ExplicitName = captured.explicitName != 0 + snapshot.HeadingLevel = int(captured.headingLevel) + snapshot.PositionInSet = int(captured.positionInSet) + snapshot.SetSize = int(captured.setSize) + snapshot.SupportsClick = captured.supportsClick != 0 + snapshot.LiveRegion = captured.liveRegion != 0 + snapshot.LayoutTable = captured.layoutTable != 0 + snapshot.ChildCount = int(captured.childCount) + snapshot.ChildCountKnown = captured.childCountKnown != 0 + snapshot.TableRowCount = int(captured.tableRowCount) + snapshot.TableColumnCount = int(captured.tableColumnCount) + snapshot.TableRowIndex = int(captured.tableRowIndex) + snapshot.TableColumnIndex = int(captured.tableColumnIndex) + snapshot.TableRowSpan = int(captured.tableRowSpan) + snapshot.TableColumnSpan = int(captured.tableColumnSpan) + snapshot.TablePositionKnown = captured.tablePositionKnown != 0 + snapshot.TableRowHeaders = sanitizeSnapshotField(C.GoString(captured.tableRowHeaders)) + snapshot.TableColumnHeaders = sanitizeSnapshotField(C.GoString(captured.tableColumnHeaders)) + C.g_free(C.gpointer(captured.tableRowHeaders)) + C.g_free(C.gpointer(captured.tableColumnHeaders)) + snapshot.CharacterCount = int(captured.characterCount) + snapshot.EmbeddedObjectCount = int(captured.embeddedObjectCount) + snapshot.XMLRoles = sanitizeSnapshotField(C.GoString(captured.xmlRoles)) + snapshot.HTMLTag = sanitizeSnapshotField(C.GoString(captured.htmlTag)) + snapshot.ElementID = sanitizeSnapshotField(C.GoString(captured.elementID)) + snapshot.CurrentValue = float64(captured.currentValue) + snapshot.MaximumValue = float64(captured.maximumValue) + snapshot.MinimumValue = float64(captured.minimumValue) + snapshot.MinimumIncrement = float64(captured.minimumIncrement) + snapshot.ValueText = sanitizeSnapshotField(C.GoString(captured.valueText)) + snapshot.TextBeforeCaret = sanitizeTextContext(C.GoString(captured.textBeforeCaret)) + snapshot.SoundGeometry = SoundGeometry{ + Object: Rectangle{X: int32(captured.objectX), Y: int32(captured.objectY), Width: int32(captured.objectWidth), Height: int32(captured.objectHeight)}, + Reference: Rectangle{X: int32(captured.referenceX), Y: int32(captured.referenceY), Width: int32(captured.referenceWidth), Height: int32(captured.referenceHeight)}, + ObjectKnown: captured.objectGeometryKnown != 0, ReferenceKnown: captured.referenceGeometryKnown != 0, + } + C.g_free(C.gpointer(captured.applicationName)) + C.g_free(C.gpointer(captured.name)) + C.g_free(C.gpointer(captured.role)) + C.g_free(C.gpointer(captured.description)) + C.g_free(C.gpointer(captured.text)) + C.g_free(C.gpointer(captured.textBeforeCaret)) + C.g_free(C.gpointer(captured.caretCharacter)) + C.g_free(C.gpointer(captured.valueText)) + C.g_free(C.gpointer(captured.xmlRoles)) + C.g_free(C.gpointer(captured.htmlTag)) + C.g_free(C.gpointer(captured.elementID)) + return snapshot +} + +func nativeObjectID(source *C.AtspiAccessible) ObjectID { + return NewObjectID( + C.GoString(C.cthulhu_accessible_bus_name(source)), + C.GoString(C.cthulhu_accessible_path(source)), + ) +} diff --git a/internal/atspi/adapter_tree_native.go b/internal/atspi/adapter_tree_native.go new file mode 100644 index 0000000..3fdbdf8 --- /dev/null +++ b/internal/atspi/adapter_tree_native.go @@ -0,0 +1,580 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "runtime/cgo" + "strings" + "time" + "unsafe" +) + +func geometryFromNative(ctx context.Context, source *C.AtspiAccessible) (Geometry, error) { + return geometryFromNativeWithBounds(ctx, source, true) +} + +func geometryFromNativeWithBounds(ctx context.Context, source *C.AtspiAccessible, captureBounds bool) (Geometry, error) { + geometry := Geometry{} + if err := ctx.Err(); err != nil { + return geometry, err + } + states := C.atspi_accessible_get_state_set(source) + if states == nil { + return geometry, ctx.Err() + } + geometry.VisibilityKnown = true + geometry.Visible = C.atspi_state_set_contains(states, C.ATSPI_STATE_VISIBLE) != 0 + geometry.Showing = C.atspi_state_set_contains(states, C.ATSPI_STATE_SHOWING) != 0 + C.g_object_unref(C.gpointer(states)) + // libatspi specifies that component bounds are meaningful only when both + // states are present. Avoid querying hidden/offscreen components. + if !captureBounds || !geometry.Visible || !geometry.Showing { + return geometry, ctx.Err() + } + if err := ctx.Err(); err != nil { + return geometry, err + } + component := C.atspi_accessible_get_component_iface(source) + if component == nil { + return geometry, ctx.Err() + } + defer C.g_object_unref(C.gpointer(component)) + if err := ctx.Err(); err != nil { + return geometry, err + } + var nativeErr *C.GError + bounds := C.atspi_component_get_extents(component, C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if bounds != nil { + defer C.g_free(C.gpointer(bounds)) + } + if nativeErr != nil { + C.g_error_free(nativeErr) + return geometry, ctx.Err() + } + if bounds != nil { + rectangle := Rectangle{X: int32(bounds.x), Y: int32(bounds.y), Width: int32(bounds.width), Height: int32(bounds.height)} + if rectangle.Valid() { + geometry.Bounds = rectangle + geometry.BoundsKnown = true + } + } + return geometry, ctx.Err() +} + +func admitNativeChild(scheduled map[ObjectID]struct{}, childID ObjectID) bool { + if _, duplicate := scheduled[childID]; duplicate { + return false + } + scheduled[childID] = struct{}{} + return true +} + +type nativeTreeCache struct { + nodes map[ObjectID]treeEntry + sources map[ObjectID]*C.AtspiAccessible +} + +// captureNativeTree copies the desktop or, when reviewBusName is set, just the +// focused application's subtree. The latter keeps flat review independent of +// unrelated AT-SPI providers that can be slow or transiently unavailable. +func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusName string, document, documentText, captureSound bool, retained map[ObjectID]*C.AtspiAccessible, cache nativeTreeCache) (ObjectID, []TreeNode, error) { + rootID := nativeObjectID(root) + if !rootID.Valid() { + C.g_object_unref(C.gpointer(root)) + return ObjectID{}, nil, fmt.Errorf("%w: native root identity is invalid", ErrInvalidTree) + } + type pendingNode struct { + source *C.AtspiAccessible + clip Rectangle + clipped bool + clipChanged bool + parentIndex int + } + pending := []pendingNode{{source: root, parentIndex: -1}} + textBytes := 0 + scheduled := map[ObjectID]struct{}{rootID: {}} + nodes := make([]TreeNode, 0, 128) + // Embedded frames can belong to another AT-SPI process. Application names + // are stable within one bus, including during flat review of a large page. + applicationNames := make(map[string]string) + releasePending := func() { + for _, item := range pending { + C.g_object_unref(C.gpointer(item.source)) + } + } + + for len(pending) != 0 { + if err := ctx.Err(); err != nil { + releasePending() + return ObjectID{}, nil, err + } + last := len(pending) - 1 + item := pending[last] + source := item.source + pending = pending[:last] + cached, known := cache.nodes[nativeObjectID(source)] + refresh := !known || cached.snapshotInvalid || cached.textInvalid || cached.geometryInvalid || cached.childrenInvalid + snapshot := cached.snapshot + if refresh { + // Only changed/new objects require synchronous provider queries. + // Reusing live siblings matches Python's event-driven web cache and + // prevents a small insertion from repeatedly timing out on a big page. + // New document nodes already have libatspi's event-maintained cache. + // Clear only entries our tree marked dirty; flushing every new node + // forces redundant provider queries across large catalogs. + if !document || known { + C.atspi_accessible_clear_cache_single(source) + } + applicationName, knownApplication := applicationNames[nativeObjectID(source).BusName] + if knownApplication { + snapshot = snapshotFromNativeWithOptions(source, !document, false, document, document && captureSound, false) + snapshot.ApplicationName = applicationName + } else { + snapshot = snapshotFromNativeWithCaret(source, !document, false, document, document && captureSound) + } + } + if _, knownApplication := applicationNames[snapshot.BusName]; !knownApplication { + applicationNames[snapshot.BusName] = snapshot.ApplicationName + } + if snapshot.Defunct { + if document && item.parentIndex >= 0 { + // Chromium can retire a descendant after exposing it in its + // parent's child list. Drop that edge and keep the live page. + children := nodes[item.parentIndex].Children + for index, child := range children { + if child == snapshot.ID() { + nodes[item.parentIndex].Children = append(children[:index], children[index+1:]...) + break + } + } + C.g_object_unref(C.gpointer(source)) + continue + } + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, fmt.Errorf("%w: bus %q path %q", ErrDeadObject, snapshot.BusName, snapshot.Path) + } + if retained != nil { + retained[snapshot.ID()] = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) + } + + geometry := cached.geometry + var geometryErr error + if refresh { + geometry, geometryErr = geometryFromNativeWithBounds(ctx, source, !document || documentText) + } + if geometryErr != nil { + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, geometryErr + } + + clip, clipped := item.clip, item.clipped + if geometry.BoundsKnown { + if clipped { + clip, _ = clip.Intersection(geometry.Bounds) + } else { + clip = geometry.Bounds + clipped = true + } + } else if geometry.VisibilityKnown && (!geometry.Visible || !geometry.Showing) && snapshot.Role != "application" && snapshot.Role != "desktop frame" { + clip, clipped = Rectangle{}, true + } + childClip, childClipped := clip, clipped + if snapshot.Role == "page tab" && geometry.Visible && geometry.Showing { + // A tab's bounds describe its header, while its accessible children + // can be the displayed page below it. Preserve the parent viewport. + childClip, childClipped = item.clip, item.clipped + } + text := VisibleText{Limited: true} + if (!document || documentText) && textBytes < maxTreeTextBytes { + if known && !cached.textInvalid && !cached.geometryInvalid && !item.clipChanged { + text = cached.text + } else { + var textErr error + text, textErr = textFromNative(ctx, source, geometry, snapshot.Role, clip) + if textErr != nil { + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, textErr + } + } + size := 0 + for _, r := range text.Ranges { + size += len(r.Text) + } + if textBytes+size > maxTreeTextBytes { + text = VisibleText{Limited: true} + } else { + textBytes += size + } + } + + cachedChildren := known && !cached.childrenInvalid + for _, id := range cached.children { + if cache.sources[id] == nil { + cachedChildren = false + break + } + } + var childCountError *C.GError + childCount := len(cached.children) + if !cachedChildren { + // Snapshot refresh above already cleared dirty entries. Clearing + // again discards the state and interface data just fetched. + childCount = int(C.atspi_accessible_get_child_count(source, &childCountError)) + } + if childCountError != nil { + err := nativeError("query AT-SPI child count", childCountError) + C.g_error_free(childCountError) + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, err + } + if childCount < 0 { + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, errors.New("query AT-SPI child count: negative result") + } + if reviewBusName == "" && len(scheduled)+childCount > maxTreeNodes { + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, fmt.Errorf("%w: native tree exceeds limit %d", ErrInvalidTree, maxTreeNodes) + } + + children := make([]ObjectID, 0, childCount) + for index := 0; index < childCount; index++ { + if err := ctx.Err(); err != nil { + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, err + } + var childError *C.GError + var child *C.AtspiAccessible + if cachedChildren { + child = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(cache.sources[cached.children[index]]))) + } else { + child = C.atspi_accessible_get_child_at_index(source, C.gint(index), &childError) + } + if childError != nil { + err := nativeError("query AT-SPI child", childError) + C.g_error_free(childError) + if child != nil { + C.g_object_unref(C.gpointer(child)) + } + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, err + } + if child == nil { + // Child lists can change between the count and lookup calls. Match + // Python Cthulhu's traversal policy: omit an unavailable child while + // retaining the rest of the current parent snapshot. A missing child + // must not prevent flat review of an otherwise live focused window. + continue + } + childID := nativeObjectID(child) + if !childID.Valid() { + C.g_object_unref(C.gpointer(child)) + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, fmt.Errorf("%w: native child identity is invalid", ErrInvalidTree) + } + if reviewBusName != "" && childID.BusName != reviewBusName { + C.g_object_unref(C.gpointer(child)) + continue + } + if len(scheduled) >= maxTreeNodes { + C.g_object_unref(C.gpointer(child)) + C.g_object_unref(C.gpointer(source)) + releasePending() + return ObjectID{}, nil, fmt.Errorf("%w: native tree exceeds limit %d", ErrInvalidTree, maxTreeNodes) + } + if !admitNativeChild(scheduled, childID) { + // Chromium can transiently expose the same accessible through more + // than one child edge while replacing document content. Python's + // flat-review traversal tolerates that provider defect. Keep the + // first edge so the owned snapshot remains a proper tree rather than + // rejecting the entire active window. + C.g_object_unref(C.gpointer(child)) + continue + } + children = append(children, childID) + pending = append(pending, pendingNode{source: child, clip: childClip, clipped: childClipped, + clipChanged: item.clipChanged || !known || cached.geometryInvalid, parentIndex: len(nodes)}) + } + nodes = append(nodes, TreeNode{Snapshot: snapshot, Children: children, Geometry: geometry, Text: text}) + C.g_object_unref(C.gpointer(source)) + } + return rootID, nodes, nil +} + +//export cthulhuAtspiTreeCaptureCallback +func cthulhuAtspiTreeCaptureCallback(userData unsafe.Pointer) { + if userData == nil { + return + } + callbackHandle := cgo.Handle(C.cthulhu_callback_value((*C.cthulhu_callback_data)(userData))) + adapter, ok := callbackHandle.Value().(*Adapter) + if !ok { + return + } + request := <-adapter.treeRequests + adapter.applySecureReset() + if request.ctx.Err() != nil { + request.result <- treeCaptureResult{err: request.ctx.Err()} + return + } + if adapter.secure.Load() { + request.result <- treeCaptureResult{err: errors.New("screen lock active")} + return + } + if request.transferItem { + item, err := adapter.nativeTransferItem(request.ctx, request.reviewFocus, request.positionOffset) + request.result <- treeCaptureResult{item: item, err: err} + } else if request.listActions || request.actionTarget != nil { + object := request.reviewFocus + if request.actionTarget != nil { + object = request.actionTarget.Object + } + actions, err := adapter.native_actions(request.ctx, object, request.actionTarget, request.actionName) + request.result <- treeCaptureResult{actions: actions, err: err} + } else if request.ocrGeometry { + geometry, err := adapter.nativeOCRGeometry(request.ctx, request.reviewFocus, request.ocrZone) + request.result <- treeCaptureResult{ocr: geometry, err: err} + } else if request.pointReview { + point, err := adapter.nativeReviewAtPoint(request.ctx, request.pointX, request.pointY, request.pointPID) + request.result <- treeCaptureResult{point: point, err: err} + } else if request.brailleRoute != nil { + err := adapter.routeNativeBraille(request.ctx, *request.brailleRoute) + request.result <- treeCaptureResult{err: err} + } else if request.setFocusedCaret { + err := adapter.setNativeFocusedTextCaret(request.ctx, request.reviewFocus, request.positionOffset) + request.result <- treeCaptureResult{err: err} + } else if request.readFocusedText { + content, err := adapter.readNativeFocusedText(request.ctx, request.reviewFocus) + request.result <- treeCaptureResult{content: content, err: err} + } else if request.readAttributes { + attributes, err := adapter.readNativeCharacterAttributes(request.ctx, request.reviewFocus) + request.result <- treeCaptureResult{attributes: attributes, err: err} + } else if request.contextOnly { + owner, err := adapter.nativeDocumentContext(request.ctx, request.reviewFocus) + request.result <- treeCaptureResult{owner: owner, err: err} + } else if request.caretObject.Valid() { + caret, err := adapter.readNativeDocumentCaret(request.ctx, request.caretObject, request.caretOffset, request.caretUnit) + request.result <- treeCaptureResult{caret: caret, err: err} + } else if request.activate { + err := adapter.activateNativeDocumentObject(request.ctx, request.position, request.reviewFocus) + request.result <- treeCaptureResult{err: err} + } else if request.position.Valid() { + err := adapter.setNativeDocumentPosition(request.ctx, request.position, request.reviewFocus, request.positionOffset, request.usePositionOffset) + request.result <- treeCaptureResult{err: err} + } else { + tree, err := adapter.captureDesktop(request.ctx, request.reviewFocus, request.document, request.documentText) + request.result <- treeCaptureResult{tree: tree, err: err} + } +} + +func (adapter *Adapter) applyTreeInvalidation(eventType string, event *C.AtspiEvent) bool { + if isDocumentLifecycleEvent(eventType) { + clear(adapter.navigationEcho) + sourceID := nativeObjectID(event.source) + adapter.treeMutex.Lock() + documentTree := adapter.documentTree + adapter.treeMutex.Unlock() + replacement := false + if documentTree != nil { + root, err := documentTree.Root() + if err == nil && root.ID().BusName == sourceID.BusName { + _, err = documentTree.Reference(sourceID) + replacement = errors.Is(err, ErrObjectNotFound) + } + } + adapter.invalidateTreeEvent(eventType, 0, sourceID, ObjectID{}) + // A replacement page can have a different accessible path from the + // captured document. In that case invalidating the event source is a + // no-op. Retire the old tree only when this is the focused document; + // background tabs must not displace the active browse position. + if replacement && len(adapter.nativeFocus) != 0 { + focus := adapter.nativeFocus[len(adapter.nativeFocus)-1].id + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + active, err := adapter.nativeDocumentRoot(ctx, focus) + cancel() + if err == nil { + activeID := nativeObjectID(active) + C.g_object_unref(C.gpointer(active)) + adapter.invalidateReplacedDocument(sourceID, activeID) + } + } + return false + } + childrenAdded := strings.HasPrefix(eventType, ChildrenAddedEvent) + childrenRemoved := strings.HasPrefix(eventType, ChildrenRemovedEvent) + textChanged := strings.HasPrefix(eventType, TextInsertedEvent) || strings.HasPrefix(eventType, TextDeletedEvent) || eventType == TextBoundsChangedEvent || eventType == TextAttributesChangedEvent || eventType == CaretEvent + if textChanged { + adapter.invalidateTreeEvent(eventType, int(C.cthulhu_event_detail1(event)), nativeObjectID(event.source), ObjectID{}) + return eventType == TextBoundsChangedEvent || eventType == TextAttributesChangedEvent + } + geometryChanged := eventType == BoundsChangedEvent || eventType == VisibleStateEvent || eventType == ShowingStateEvent + semanticChanged := semanticSnapshotChanged(eventType) + if !childrenAdded && !childrenRemoved && eventType != DefunctStateEvent && !geometryChanged && !semanticChanged { + return false + } + sourceID := nativeObjectID(event.source) + if semanticChanged { + adapter.invalidateTreeEvent(eventType, int(C.cthulhu_event_detail1(event)), sourceID, ObjectID{}) + return false + } + childID := ObjectID{} + if childrenRemoved { + child := C.cthulhu_event_active_descendant(event) + childID = nativeObjectID(child) + } + detail1 := int(C.cthulhu_event_detail1(event)) + // Setting Chromium's caret can retire the target or one of its descendants + // and immediately replace it with a new provider object. That defunct event + // is part of the navigation echo, not evidence that the captured document + // order changed. Preserve the semantic document snapshot for the next move; + // object and visual-review trees still invalidate normally. Suppression is + // limited to the target subtree recorded immediately before caret placement. + navigationDefunct := eventType == DefunctStateEvent && detail1 != 0 && adapter.navigationEcho[sourceID] != 0 + adapter.invalidateTreeEventWithPolicy(eventType, detail1, sourceID, childID, navigationDefunct) + if navigationDefunct { + delete(adapter.navigationEcho, sourceID) + } + return true +} + +func (adapter *Adapter) invalidateReplacedDocument(sourceID, activeID ObjectID) { + if !sourceID.Valid() || sourceID != activeID { + return + } + adapter.treeMutex.Lock() + trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} + adapter.treeMutex.Unlock() + for _, tree := range trees { + if tree == nil { + continue + } + root, err := tree.Root() + if err != nil || root.ID().BusName != sourceID.BusName { + continue + } + if _, err := tree.Reference(sourceID); err == nil { + continue + } + adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID()))) + } +} + +func (adapter *Adapter) invalidateTreeEvent(eventType string, detail1 int, sourceID, childID ObjectID) { + adapter.invalidateTreeEventWithPolicy(eventType, detail1, sourceID, childID, false) +} + +func (adapter *Adapter) invalidateTreeEventWithPolicy(eventType string, detail1 int, sourceID, childID ObjectID, preserveDocument bool) { + if eventType == DefunctStateEvent && detail1 == 0 { + return + } + if eventType != CaretEvent { + clear(adapter.documentLineCache) + } + adapter.treeMutex.Lock() + trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} + adapter.treeMutex.Unlock() + for index, tree := range trees { + if preserveDocument && index == 1 { + continue + } + // Structural matching consumes the semantic document snapshot. Other + // owned trees remain usable for object/flat review until their own + // established text, geometry, child, or lifecycle invalidation. + if semanticSnapshotChanged(eventType) && index != 1 { + continue + } + // Caret position and viewport bounds do not change semantic order, + // names or hidden state. Flat review still needs these invalidations. + if index == 1 && (eventType == CaretEvent || eventType == BoundsChangedEvent || eventType == TextBoundsChangedEvent || eventType == ShowingStateEvent) { + continue + } + // The visual-line tree receives layout, visibility, and text + // invalidations. A caret event supplies its current offset separately, + // so it does not make otherwise-current line geometry stale. + if index == 2 && eventType == CaretEvent { + continue + } + if index == 1 && tree != nil && (strings.HasPrefix(eventType, ChildrenAddedEvent) || + strings.HasPrefix(eventType, ChildrenRemovedEvent) && !childID.Valid()) { + adapter.treeInvalidations.Add(uint64(tree.MarkChildrenChanged(sourceID))) + continue + } + adapter.invalidateCapturedTree(tree, eventType, detail1, sourceID, childID) + } +} + +func (adapter *Adapter) invalidateCapturedTree(tree *AccessibleTree, eventType string, detail1 int, sourceID, childID ObjectID) { + if tree == nil { + return + } + + removed := 0 + switch { + case isDocumentLifecycleEvent(eventType): + // Browsers can reuse an accessible path when replacing a document. + // Retire its generation, including all descendants, before recapture. + removed = tree.Invalidate(sourceID) + case strings.HasPrefix(eventType, TextInsertedEvent) || strings.HasPrefix(eventType, TextDeletedEvent) || eventType == TextBoundsChangedEvent || eventType == TextAttributesChangedEvent || eventType == CaretEvent: + removed = tree.InvalidateText(sourceID) + case eventType == BoundsChangedEvent || eventType == VisibleStateEvent || eventType == ShowingStateEvent: + removed = tree.InvalidateGeometry(sourceID) + case eventType == DefunctStateEvent: + removed = tree.Invalidate(sourceID) + case semanticSnapshotChanged(eventType): + // Refresh changed semantics without discarding document/caret identities. + // Events outside this tree remain a no-op. + removed = tree.InvalidateSnapshot(sourceID) + case strings.HasPrefix(eventType, ChildrenAddedEvent): + removed = tree.InvalidateChildren(sourceID) + case strings.HasPrefix(eventType, ChildrenRemovedEvent): + if childID.Valid() { + removed = tree.Invalidate(childID) + } else { + removed = tree.InvalidateChildren(sourceID) + } + } + adapter.treeInvalidations.Add(uint64(removed)) +} + +func semanticSnapshotChanged(eventType string) bool { + if eventType == AccessibleNameEvent || eventType == ValueChangedEvent || eventType == AccessibleValueEvent { + return true + } + switch eventType { + case "object:state-changed:checked", + "object:state-changed:expanded", + "object:state-changed:indeterminate", + "object:state-changed:invalid-entry", + "object:state-changed:pressed", + "object:state-changed:read-only", + "object:state-changed:required", + "object:state-changed:selected", + "object:state-changed:sensitive", + "object:state-changed:visited": + return true + default: + return false + } +} + +func isDocumentLifecycleEvent(eventType string) bool { + return eventType == DocumentReloadEvent || eventType == DocumentLoadCompleteEvent || eventType == DocumentLoadStoppedEvent +} diff --git a/internal/atspi/adapter_tree_test.go b/internal/atspi/adapter_tree_test.go new file mode 100644 index 0000000..4bd9845 --- /dev/null +++ b/internal/atspi/adapter_tree_test.go @@ -0,0 +1,409 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +func TestAdmitNativeChildKeepsFirstTreeEdge(t *testing.T) { + child := NewObjectID(":1.42", "/child") + scheduled := make(map[ObjectID]struct{}) + if !admitNativeChild(scheduled, child) { + t.Fatal("first child edge was rejected") + } + if admitNativeChild(scheduled, child) { + t.Fatal("duplicate child edge was accepted") + } + if len(scheduled) != 1 { + t.Fatalf("scheduled children = %d, want 1", len(scheduled)) + } +} + +func TestDocumentCompletionRetainsNativeFocusWithoutChromeFocus(t *testing.T) { + chrome := NewObjectID(":1.42", "/address") + for _, eventType := range []string{DocumentLoadCompleteEvent, DocumentLoadStoppedEvent, BusyStateEvent} { + t.Run(eventType, func(t *testing.T) { + if !shouldRetainNativeFocus(eventType, 0, ObjectID{}) { + t.Fatal("completion cannot resolve its document without a retained native source") + } + if shouldRetainNativeFocus(eventType, 0, chrome) { + t.Fatal("background completion displaced retained chrome focus") + } + }) + } + for _, eventType := range []string{DocumentReloadEvent, BusyStateEvent} { + if shouldRetainNativeFocus(eventType, 1, ObjectID{}) { + t.Fatalf("loading start %q acquired document focus", eventType) + } + } +} + +func TestDetachedDocumentWindowRequiresSelectedOwnedTab(t *testing.T) { + document := NewObjectID(":1.42", "/old_document") + window := NewObjectID(":1.42", "/browser_window") + other := NewObjectID(":1.42", "/other_window") + for _, tc := range []struct { + name string + active, selected, known ObjectID + eventType string + detail int + want bool + }{ + {"returned selected tab", ObjectID{}, window, window, FocusedStateEvent, 1, true}, + {"other window selected", ObjectID{}, other, window, FocusedStateEvent, 1, false}, + {"background focus loss", ObjectID{}, window, window, FocusedStateEvent, 0, false}, + {"active focus loss", document, ObjectID{}, window, FocusedStateEvent, 0, false}, + {"background caret", ObjectID{}, window, window, CaretEvent, 1, false}, + {"established document caret", document, ObjectID{}, window, CaretEvent, 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := restoredDocumentWindowAllowed(document, tc.active, tc.selected, tc.known, tc.eventType, tc.detail); got != tc.want { + t.Fatalf("window restoration = %t, want %t", got, tc.want) + } + }) + } +} + +func TestCaptureDesktopRejectsUnavailableAdapterAndCanceledContext(t *testing.T) { + adapter := New() + if _, err := adapter.CaptureDesktop(context.Background()); err == nil { + t.Fatal("capture succeeded before adapter start") + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := adapter.CaptureDesktop(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled capture error = %v", err) + } + if _, err := adapter.CaptureDesktop(nil); err == nil { + t.Fatal("capture accepted a nil context") + } +} + +func TestDocumentCaptureTreeIsIndependentAndInvalidated(t *testing.T) { + for _, eventType := range []string{ChildrenAddedEvent, DefunctStateEvent, DocumentReloadEvent, "shutdown"} { + t.Run(eventType, func(t *testing.T) { + adapter := New() + adapter.tree = testTree(t) + adapter.documentTree = testTree(t) + root, _ := adapter.tree.Root() + reviewRef, _ := adapter.tree.Reference(testID("/first")) + documentRef, _ := adapter.documentTree.Reference(testID("/first")) + if eventType == "shutdown" { + adapter.invalidateTree() + } else { + adapter.invalidateTreeEvent(eventType, 1, root.ID(), ObjectID{}) + } + if _, err := adapter.tree.Resolve(reviewRef); !errors.Is(err, ErrStaleObject) { + t.Fatalf("review reference survived %s: %v", eventType, err) + } + if eventType == ChildrenAddedEvent { + if _, err := adapter.documentTree.Resolve(documentRef); err != nil { + t.Fatalf("insertion retired surviving document child: %v", err) + } + documentRoot, _ := adapter.documentTree.Root() + if _, _, err := adapter.documentTree.FirstChild(documentRoot); !errors.Is(err, ErrIncompleteTree) { + t.Fatalf("insertion left old child order navigable: %v", err) + } + } else if _, err := adapter.documentTree.Resolve(documentRef); !errors.Is(err, ErrStaleObject) { + t.Fatalf("document reference survived %s: %v", eventType, err) + } + }) + } + adapter := New() + adapter.tree = testTree(t) + root, _ := adapter.tree.Root() + reviewRef, _ := adapter.tree.Reference(testID("/first")) + snapshot := NewSnapshot(root.ID().Path, "Document", "document web", "", "") + snapshot.BusName = root.ID().BusName + document, err := adapter.installCapturedTree(root.ID(), []TreeNode{{Snapshot: snapshot}}, true, false) + if err != nil { + t.Fatal(err) + } + if document == adapter.tree { + t.Fatal("document capture replaced review tree") + } + if _, err := adapter.tree.Resolve(reviewRef); err != nil { + t.Fatalf("document capture invalidated independent review position: %v", err) + } +} + +func TestReplacementDocumentRetiresOnlyActiveApplicationTree(t *testing.T) { + adapter := New() + adapter.documentTree = testTree(t) + old, err := adapter.documentTree.Reference(testID("/first")) + if err != nil { + t.Fatal(err) + } + replacement := testID("/replacement") + adapter.invalidateTreeEvent(DocumentLoadCompleteEvent, 0, replacement, ObjectID{}) + if _, err := adapter.documentTree.Resolve(old); err != nil { + t.Fatalf("unknown load source unexpectedly retired tree: %v", err) + } + adapter.invalidateReplacedDocument(replacement, testID("/background")) + if _, err := adapter.documentTree.Resolve(old); err != nil { + t.Fatalf("inactive document retired tree: %v", err) + } + adapter.invalidateReplacedDocument(replacement, replacement) + if _, err := adapter.documentTree.Resolve(old); !errors.Is(err, ErrStaleObject) { + t.Fatalf("active replacement left old document available: %v", err) + } +} + +func TestDocumentTextTreeTracksLayoutInvalidationIndependently(t *testing.T) { + adapter := New() + adapter.documentTree = testTree(t) + adapter.documentTextTree = testTree(t) + semantic, _ := adapter.documentTree.Reference(testID("/first")) + visual, _ := adapter.documentTextTree.Reference(testID("/first")) + + adapter.invalidateTreeEvent(BoundsChangedEvent, 0, testID("/first"), ObjectID{}) + if _, err := adapter.documentTree.Geometry(semantic); err != nil { + t.Fatalf("semantic document geometry was invalidated: %v", err) + } + if _, err := adapter.documentTextTree.Geometry(visual); !errors.Is(err, ErrStaleGeometry) { + t.Fatalf("visual document geometry error = %v, want stale geometry", err) + } + + adapter.documentTextTree = testTree(t) + visual, _ = adapter.documentTextTree.Reference(testID("/first")) + adapter.invalidateTreeEvent(CaretEvent, 1, testID("/first"), ObjectID{}) + if _, err := adapter.documentTextTree.VisibleText(visual); err != nil { + t.Fatalf("caret event invalidated reusable visual text: %v", err) + } +} + +func TestAdapterTreeInvalidationPolicy(t *testing.T) { + t.Run("unrelated semantic events preserve document", func(t *testing.T) { + for _, eventType := range []string{AccessibleNameEvent, AccessibleValueEvent, ValueChangedEvent, "object:state-changed:selected", "object:state-changed:checked", "object:state-changed:sensitive"} { + t.Run(eventType, func(t *testing.T) { + adapter := New() + adapter.documentTree = testTree(t) + cached, _ := adapter.documentTree.Reference(testID("/first")) + for _, source := range []ObjectID{testID("/browser-chrome"), {BusName: ":1.999", Path: cached.ID().Path}, {}} { + adapter.invalidateTreeEvent(eventType, 1, source, ObjectID{}) + if _, err := adapter.documentTree.Resolve(cached); err != nil { + t.Fatalf("outside event from %v discarded document: %v", source, err) + } + } + if got := adapter.TreeInvalidations(); got != 0 { + t.Fatalf("unrelated events invalidated %d objects", got) + } + adapter.invalidateTreeEvent(eventType, 1, cached.ID(), ObjectID{}) + if _, err := adapter.documentTree.Resolve(cached); !errors.Is(err, ErrStaleSnapshot) { + t.Fatalf("in-document change did not retire snapshot: %v", err) + } + }) + } + }) + + t.Run("semantic change preserves identities for refresh", func(t *testing.T) { + tree := testTree(t) + adapter := New() + adapter.documentTree = tree + cached, _ := tree.Reference(testID("/first")) + adapter.invalidateTreeEvent(AccessibleNameEvent, 0, cached.ID(), ObjectID{}) + if _, err := tree.Resolve(cached); !errors.Is(err, ErrStaleSnapshot) { + t.Fatalf("semantic snapshot error = %v, want stale semantics", err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + current, err := tree.Reference(cached.ID()) + if err != nil || current != cached { + t.Fatalf("name change retired the current identity: %v", err) + } + cachedNodes := tree.nativeRefresh(root.ID()) + if len(cachedNodes) == 0 || !cachedNodes[cached.ID()].snapshotInvalid { + t.Fatal("semantic refresh omitted the dirty object") + } + nodes := make([]TreeNode, 0, len(cachedNodes)) + for id, entry := range cachedNodes { + if id == cached.ID() { + entry.snapshot.Name = "Updated name" + } + nodes = append(nodes, TreeNode{Snapshot: entry.snapshot, Children: entry.children, Geometry: entry.geometry, Text: entry.text}) + } + if err := tree.Replace(root.ID(), nodes); err != nil { + t.Fatal(err) + } + snapshot, err := tree.Resolve(cached) + if err != nil || snapshot.Name != "Updated name" { + t.Fatalf("refreshed object = %+v, %v", snapshot, err) + } + tree.InvalidateSnapshot(cached.ID()) + tree.MarkChildrenChanged(root.ID()) + if plan := tree.nativeRefresh(root.ID()); !plan[root.ID()].childrenInvalid || !plan[cached.ID()].snapshotInvalid { + t.Fatal("incremental refresh lost a dirty child list or snapshot") + } + tree.Invalidate(root.ID()) + if plan := tree.nativeRefresh(root.ID()); plan != nil { + t.Fatal("incremental refresh retained a replaced document") + } + }) + + t.Run("known removal", func(t *testing.T) { + tree := testTree(t) + adapter := New() + adapter.tree = tree + removed, _ := tree.Reference(testID("/second")) + kept, _ := tree.Reference(testID("/first")) + adapter.invalidateTreeEvent(ChildrenRemovedEvent, 0, testID("/root"), removed.ID()) + if _, err := tree.Resolve(removed); !errors.Is(err, ErrStaleObject) { + t.Fatalf("removed reference error = %v", err) + } + if _, err := tree.Resolve(kept); err != nil { + t.Fatalf("unrelated sibling became stale: %v", err) + } + if got := adapter.TreeInvalidations(); got != 2 { + t.Fatalf("invalidation count = %d, want 2", got) + } + }) + + t.Run("addition invalidates cached children", func(t *testing.T) { + tree := testTree(t) + adapter := New() + adapter.tree = tree + root, _ := tree.Root() + child, _, _ := tree.FirstChild(root) + adapter.invalidateTreeEvent(ChildrenAddedEvent+":system", 1, root.ID(), ObjectID{}) + if _, err := tree.Resolve(root); err != nil { + t.Fatalf("parent became stale: %v", err) + } + if _, err := tree.Resolve(child); !errors.Is(err, ErrStaleObject) { + t.Fatalf("cached child error = %v, want stale object", err) + } + if got := adapter.TreeInvalidations(); got != 3 { + t.Fatalf("invalidation count = %d, want 3", got) + } + }) + + t.Run("defunct false is ignored", func(t *testing.T) { + tree := testTree(t) + adapter := New() + adapter.tree = tree + root, _ := tree.Root() + adapter.invalidateTreeEvent(DefunctStateEvent, 0, root.ID(), ObjectID{}) + if _, err := tree.Resolve(root); err != nil { + t.Fatalf("false defunct state invalidated root: %v", err) + } + adapter.invalidateTreeEvent(DefunctStateEvent, 1, root.ID(), ObjectID{}) + if _, err := tree.Resolve(root); !errors.Is(err, ErrStaleObject) { + t.Fatalf("defunct root error = %v, want stale object", err) + } + }) + + t.Run("navigation defunct preserves semantic document", func(t *testing.T) { + owned := testTree(t) + document := testTree(t) + visual := testTree(t) + adapter := New() + adapter.tree = owned + adapter.documentTree = document + adapter.documentTextTree = visual + target := testID("/first") + adapter.invalidateTreeEventWithPolicy(DefunctStateEvent, 1, target, ObjectID{}, true) + for name, tree := range map[string]*AccessibleTree{"owned": owned, "visual": visual} { + if _, err := tree.Reference(target); !errors.Is(err, ErrObjectNotFound) { + t.Fatalf("%s target error = %v, want missing", name, err) + } + } + if _, err := document.Reference(target); err != nil { + t.Fatalf("semantic document target was invalidated: %v", err) + } + }) +} + +func TestAdapterInvalidatesGeometryWithoutRemovingObjects(t *testing.T) { + for _, eventType := range []string{BoundsChangedEvent, VisibleStateEvent, ShowingStateEvent} { + for _, detail := range []int{0, 1} { + t.Run(eventType+fmt.Sprint(detail), func(t *testing.T) { + tree := testTree(t) + adapter := New() + adapter.tree = tree + second, _ := tree.Reference(testID("/second")) + child, _, _ := tree.FirstChild(second) + adapter.invalidateTreeEvent(eventType, detail, second.ID(), ObjectID{}) + for _, ref := range []ObjectReference{second, child} { + if _, err := tree.Geometry(ref); !errors.Is(err, ErrStaleGeometry) { + t.Fatalf("geometry error = %v", err) + } + if _, err := tree.Resolve(ref); err != nil { + t.Fatalf("object invalidated: %v", err) + } + } + }) + } + } +} + +func TestQueuedDocumentPositionRejectsDepartedFocus(t *testing.T) { + tree, err := NewAccessibleTree(testID("/root"), documentTestNodes()) + if err != nil { + t.Fatal(err) + } + position, err := tree.Reference(testID("/heading")) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + path string + allowed bool + }{ + {"/heading", true}, {"/link", true}, {"/chrome", false}, {"/other", false}, {"/gone", false}, + } { + if err := documentPositionMatchesFocus(tree, position, testID(tc.path)); (err == nil) != tc.allowed { + t.Fatalf("focus %s: %v", tc.path, err) + } + } + tree.Invalidate(testID("/heading")) + if err := documentPositionMatchesFocus(tree, position, testID("/link")); err == nil { + t.Fatal("stale target accepted") + } +} + +func TestSecureInputCancelsCapturesAcrossLockTransitions(t *testing.T) { + adapter := New() + before, cancelBefore := adapter.secureRequestContext(context.Background()) + defer cancelBefore() + adapter.SetSecureInputActive(true) + select { + case <-before.Done(): + case <-time.After(time.Second): + t.Fatal("lock did not cancel in-flight capture") + } + during, cancelDuring := adapter.secureRequestContext(context.Background()) + defer cancelDuring() + if during.Err() == nil { + t.Fatal("capture allowed while locked") + } + adapter.SetSecureInputActive(false) + after, cancelAfter := adapter.secureRequestContext(context.Background()) + defer cancelAfter() + if after.Err() != nil { + t.Fatal("unlock left captures canceled") + } + if before.Err() == nil || during.Err() == nil { + t.Fatal("unlock revived old capture") + } +} diff --git a/internal/atspi/adapter_window_title.go b/internal/atspi/adapter_window_title.go new file mode 100644 index 0000000..71a7f5f --- /dev/null +++ b/internal/atspi/adapter_window_title.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import "git.stormux.org/storm/cthulhu/internal/platform/xorg" + +// ForegroundTitle binds copied title metadata to the foreground ownership epoch. +type ForegroundTitle struct { + Title xorg.WindowTitle + Epoch uint64 +} + +func (adapter *Adapter) ForegroundTitle() ForegroundTitle { + if title := adapter.foregroundTitle.Load(); title != nil { + return *title + } + return ForegroundTitle{} +} + +func (adapter *Adapter) TitleChanges() <-chan struct{} { return adapter.titleChanges } + +// Called only by the GLib owner; consumers never issue X11 requests. +func (adapter *Adapter) refreshWindowTitle() { + state := adapter.Foreground() + value := ForegroundTitle{Title: adapter.foregroundReader.ReadTitle(state.Client.Window), Epoch: state.Epoch} + if value == adapter.ForegroundTitle() { + return + } + adapter.foregroundTitle.Store(&value) + select { + case adapter.titleChanges <- struct{}{}: + default: + } +} diff --git a/internal/atspi/alert_native_test.go b/internal/atspi/alert_native_test.go new file mode 100644 index 0000000..c439f66 --- /dev/null +++ b/internal/atspi/alert_native_test.go @@ -0,0 +1,313 @@ +// 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" + "strings" + "testing" + "time" +) + +func TestNativeGTKAlertMessage(t *testing.T) { + window := os.Getenv("CTHULHU_ALERT_WINDOW") + if window == "" { + t.Skip("requires the private GTK alert fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + for _, args := range [][]string{{"windowfocus", window}, {"key", "--clearmodifiers", "u"}} { + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool %s: %v: %s", args[0], err, output) + } + } + for { + select { + case <-ctx.Done(): + t.Fatal("GTK alert window:create with its message was not captured") + case event := <-adapter.Events(): + if event.Type != WindowCreateEvent || event.Source.Role != "alert" { + continue + } + if event.Source.Text != "Version 0.6.0 is available. Open the releases page in your browser?" { + t.Fatalf("alert text = %q", event.Source.Text) + } + return + } + } +} + +func TestNativeYADQuestionMessage(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_YAD") != "1" { + t.Skip("requires private Xvfb/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + command := exec.CommandContext(ctx, "yad", "--image", "dialog-question", "--title", "Alert", + "--button=gtk-yes:0", "--button=gtk-no:1", + "--text", "Microsoft Windows has been found! Would you like to remove it?") + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _ = command.Wait() + }() + var yadWindow string + for yadWindow == "" && ctx.Err() == nil { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Alert$").Output() + if err == nil { + yadWindow = strings.TrimSpace(string(output)) + } + if yadWindow == "" { + time.Sleep(20 * time.Millisecond) + } + } + if yadWindow == "" { + t.Fatal("YAD window did not appear") + } + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(yadWindow, "\n")[0]).CombinedOutput(); err != nil { + t.Fatalf("focus YAD window: %v: %s", err, output) + } + for { + select { + case <-ctx.Done(): + t.Fatal("YAD question was not captured on activation or button focus") + case event := <-adapter.Events(): + question := "Microsoft Windows has been found! Would you like to remove it?" + if event.Type == WindowActivatedEvent && event.Source.Role == "frame" && event.Source.Name == "Alert" { + if !event.Window.Valid() || event.Window != event.Source.ID() { + t.Fatalf("YAD frame ownership = %#v, source = %#v", event.Window, event.Source.ID()) + } + if event.Source.Text == question { + return + } + if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil { + t.Fatalf("focus YAD button: %v: %s", err, output) + } + } + if (event.Type == FocusEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && + event.Source.Role == "button" && event.WindowMessage == question { + if !event.Window.Valid() || event.Window == event.Source.ID() { + t.Fatalf("YAD button ownership = %#v, source = %#v", event.Window, event.Source.ID()) + } + return + } + } + } +} + +func TestNativeYADListMessage(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_YAD") != "1" { + t.Skip("requires private Xvfb/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + command := exec.CommandContext(ctx, "yad", "--list", "--title=Select Sound Type", + "--text=Which sound type would you like to use?", "--column=Option", "--no-headers", + "--selectable-labels", "--search-column=1", "--height=400", "--width=600") + command.Stdin = strings.NewReader("stereo\nhrtf\n") + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _ = command.Wait() + }() + var yadWindow string + for yadWindow == "" && ctx.Err() == nil { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Select Sound Type$").Output() + if err == nil { + yadWindow = strings.TrimSpace(string(output)) + } + if yadWindow == "" { + time.Sleep(20 * time.Millisecond) + } + } + if yadWindow == "" { + t.Fatal("YAD list window did not appear") + } + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(yadWindow, "\n")[0]).CombinedOutput(); err != nil { + t.Fatalf("focus YAD list window: %v: %s", err, output) + } + for { + select { + case <-ctx.Done(): + t.Fatal("YAD list question was not captured on activation") + case event := <-adapter.Events(): + if event.Source.ApplicationName != "yad" || + (event.Type != WindowActivatedEvent && event.Type != FocusEvent && + (event.Type != FocusedStateEvent || event.Detail1 != 1)) { + continue + } + if event.Source.Role != "tree table" && event.Source.Role != "frame" { + continue + } + question := event.WindowMessage + if event.Source.Role == "frame" && event.Type == WindowActivatedEvent { + question = event.Source.Text + } + if question == "" { + continue + } + if question != "Which sound type would you like to use?" { + t.Fatalf("YAD list question = %q on %q activation", question, event.Source.Role) + } + return + } + } +} + +func TestNativeYADPowerListMessage(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_YAD") != "1" { + t.Skip("requires private Xvfb/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + command := exec.CommandContext(ctx, "yad", "--title", "I38", "--text", "Power options", "--list", + "--column", "Action", "--column", "Description", + "Power off", "Shut down the system", "Reboot", "Restart the system", + "Log out", "Exit the current session", "--print-column=1", "--separator=", + "--button", "Select:0", "--button", "Cancel:1") + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _ = command.Wait() + }() + var window string + for window == "" && ctx.Err() == nil { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^I38$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + if window == "" { + time.Sleep(20 * time.Millisecond) + } + } + if window == "" { + t.Fatal("I38 list did not appear") + } + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(window, "\n")[0]).CombinedOutput(); err != nil { + t.Fatalf("focus I38 list: %v: %s", err, output) + } + for { + select { + case <-ctx.Done(): + t.Fatal("I38 power prompt was not captured") + case event := <-adapter.Events(): + if event.Source.ApplicationName == "yad" && (event.Source.Role == "tree table" || event.Source.Role == "table cell") && + (event.Type == WindowActivatedEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && + event.WindowMessage == "Power options" { + return + } + } + } +} + +func TestNativeGTKRadioGroupName(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_RADIO") != "1" { + t.Skip("requires private Xvfb/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_radio_group.py") + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = command.Process.Kill() + _ = command.Wait() + }() + var window string + for window == "" && ctx.Err() == nil { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Radio group probe$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + if window == "" { + time.Sleep(20 * time.Millisecond) + } + } + if window == "" { + t.Fatal("GTK radio window did not appear") + } + for _, args := range [][]string{{"windowfocus", strings.Split(window, "\n")[0]}, {"key", "Tab"}} { + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool %s: %v: %s", args[0], err, output) + } + } + for { + select { + case <-ctx.Done(): + t.Fatal("GTK radio group name was not captured") + case event := <-adapter.Events(): + if event.Source.Role == "radio button" && event.Source.Focused { + if event.Source.RadioGroupName != "Punctuation Level" { + t.Fatalf("radio group = %q", event.Source.RadioGroupName) + } + return + } + } + } +} diff --git a/internal/atspi/braille_native.go b/internal/atspi/braille_native.go new file mode 100644 index 0000000..a2e5331 --- /dev/null +++ b/internal/atspi/braille_native.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#cgo pkg-config: atspi-2 glib-2.0 gobject-2.0 +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "unicode/utf8" +) + +// BrailleRoute refers to the exact source span which produced the display. +// Revalidate it on the GLib thread so edits or focus changes cannot route a +// delayed hardware key into different content. +type BrailleRoute struct { + Focus ObjectID + Position ObjectReference // Valid only for document browse routing. + Line string + Start, Offset int +} + +func (adapter *Adapter) RouteBraille(ctx context.Context, route BrailleRoute) error { + _, err := adapter.documentOperation(ctx, treeCaptureRequest{brailleRoute: &route}) + return err +} + +func validBrailleRoute(snapshot Snapshot, route BrailleRoute) bool { + return route.Focus.Valid() && snapshot.ID() == route.Focus && !snapshot.Defunct && snapshot.StatesKnown && snapshot.Focused && + snapshot.Role != "password text" && snapshot.TextCaretKnown && + snapshot.BrailleLineStart == route.Start && snapshot.BrailleLine == route.Line && + route.Start >= 0 && route.Offset >= route.Start && + route.Offset-route.Start <= utf8.RuneCountInString(route.Line) +} + +func (adapter *Adapter) routeNativeBraille(ctx context.Context, route BrailleRoute) error { + if err := ctx.Err(); err != nil { + return err + } + if route.Position.Valid() { + return adapter.route_document_braille(ctx, route) + } + if !adapter.nativeFocusIs(route.Focus) { + return ErrStaleObject + } + source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source + // Request caret capture even for read-only text. Ordinary focus snapshots + // deliberately avoid querying non-editable text on the event hot path. + snapshot := snapshotFromNativeWithCaret(source, true, true, false, false) + if !validBrailleRoute(snapshot, route) { + return ErrStaleObject + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return ErrNoDocumentCaret + } + defer C.g_object_unref(C.gpointer(text)) + if err := ctx.Err(); err != nil { + return err + } + var nativeErr *C.GError + placed := C.atspi_text_set_caret_offset(text, C.gint(route.Offset), &nativeErr) + if nativeErr != nil { + err := nativeError("route braille caret", nativeErr) + C.g_error_free(nativeErr) + return err + } + if placed == 0 { + return ErrNoDocumentCaret + } + return nil +} + +func (adapter *Adapter) route_document_braille(ctx context.Context, route BrailleRoute) error { + adapter.treeMutex.Lock() + tree := adapter.documentTree + adapter.treeMutex.Unlock() + if tree == nil || len(adapter.nativeFocus) == 0 { + return ErrStaleObject + } + snapshot, err := tree.Resolve(route.Position) + if err != nil || snapshot.Role == "password text" || snapshot.Defunct { + return ErrStaleObject + } + if err = documentPositionMatchesFocus(tree, route.Position, route.Focus); err != nil { + return err + } + latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id + if err = documentPositionMatchesFocus(tree, route.Position, latest); err != nil { + return err + } + source := adapter.nativeDocument[route.Position.ID()] + if source == nil { + return ErrStaleObject + } + // Recheck the protected state and foreground before reading fresh content. + C.atspi_accessible_clear_cache_single(source) + fresh := snapshotFromNativeWithCaret(source, false, false, false, false) + if fresh.Role != snapshot.Role || fresh.Defunct || fresh.Role == "password text" { + return ErrStaleObject + } + window := C.cthulhu_window_ancestor(source) + if window == nil { + return ErrStaleObject + } + foreground := adapter.matchesForeground(window, nativeObjectID(window)) + C.g_object_unref(C.gpointer(window)) + if !foreground { + return ErrStaleObject + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return ErrNoDocumentCaret + } + defer C.g_object_unref(C.gpointer(text)) + length := utf8.RuneCountInString(route.Line) + if route.Start < 0 || route.Offset < route.Start || route.Offset > route.Start+length || length == 0 { + return ErrStaleObject + } + count := int(C.atspi_text_get_character_count(text, nil)) + if route.Start+length > count { + return ErrStaleObject + } + var nativeErr *C.GError + content := C.atspi_text_get_text(text, C.gint(route.Start), C.gint(route.Start+length), &nativeErr) + if nativeErr != nil { + err := nativeError("validate document braille text", nativeErr) + C.g_error_free(nativeErr) + return err + } + unchanged := C.GoString(content) == route.Line + C.g_free(C.gpointer(content)) + if !unchanged { + return ErrStaleObject + } + return adapter.setNativeDocumentPosition(ctx, route.Position, route.Focus, route.Offset, true) +} diff --git a/internal/atspi/braille_test.go b/internal/atspi/braille_test.go new file mode 100644 index 0000000..16abefd --- /dev/null +++ b/internal/atspi/braille_test.go @@ -0,0 +1,119 @@ +package atspi + +import ( + "context" + "errors" + "strings" + "testing" + "unicode/utf8" +) + +func TestBrailleCaptureKeepsTruncatedUnicodeOrigin(t *testing.T) { + raw := strings.Repeat("猫", 2000) + " é" + var snapshot Snapshot + captureBrailleLine(&snapshot, raw, raw[:len(raw)-len("é")], 19) + removed := utf8.RuneCountInString(raw) - utf8.RuneCountInString(snapshot.BrailleLine) + if snapshot.BrailleLineStart != 19+removed || snapshot.BrailleCaret != 2021 || !strings.HasSuffix(snapshot.BrailleLine, " é") { + t.Fatalf("origin=%d caret=%d", snapshot.BrailleLineStart, snapshot.BrailleCaret) + } +} + +func TestBrailleRouteRejectsChangedLineAndFocus(t *testing.T) { + s := NewSnapshot("/entry", "Message", "entry", "", "é猫") + s.BusName, s.TextCaretKnown = ":1.42", true + s.StatesKnown, s.Focused = true, true + captureBrailleLine(&s, "é猫", "é", 12) + route := BrailleRoute{Focus: s.ID(), Line: "é猫", Start: 12, Offset: 14} + if !validBrailleRoute(s, route) { + t.Fatal("valid EOL route rejected") + } + for _, change := range []func(*Snapshot){ + func(s *Snapshot) { s.BusName = ":1.43" }, + func(s *Snapshot) { s.Path = "/other" }, + func(s *Snapshot) { s.BrailleLine = "ab" }, + func(s *Snapshot) { s.BrailleLineStart++ }, + func(s *Snapshot) { s.Defunct = true }, + func(s *Snapshot) { s.Focused = false }, + func(s *Snapshot) { s.StatesKnown = false }, + func(s *Snapshot) { s.Role = "password text" }, + func(s *Snapshot) { s.TextCaretKnown = false }, + } { + changed := s + change(&changed) + if validBrailleRoute(changed, route) { + t.Fatalf("accepted stale/protected route %+v", changed) + } + } + for _, offset := range []int{-1, 11, 15} { + route.Offset = offset + if validBrailleRoute(s, route) { + t.Fatalf("accepted offset %d", offset) + } + } +} + +func assertNativeBrailleRouting(t *testing.T, ctx context.Context, adapter *Adapter) { + t.Helper() + wait := func(name string, offset int) Snapshot { + t.Helper() + for { + select { + case e := <-adapter.Events(): + if e.Source.Name != name { + continue + } + if offset < 0 && (e.Type == FocusEvent || e.Type == FocusedStateEvent && e.Detail1 == 1) { + return e.Source + } + if offset >= 0 && e.Type == CaretEvent && e.Source.BrailleCaret == offset { + return e.Source + } + case <-ctx.Done(): + t.Fatalf("missing native braille event for %s offset %d", name, offset) + } + } + } + pressTreeProbeKey(t, "F6") + s := wait("Caret review probe", -1) + if !s.TextCaretKnown || s.BrailleLine != "Caret 猫 text" { + t.Fatalf("native braille snapshot %+v", s) + } + route := BrailleRoute{Focus: s.ID(), Line: s.BrailleLine, Start: s.BrailleLineStart} + for _, offset := range []int{1, 6, 7, 12, 6} { + route.Offset = offset + if err := adapter.RouteBraille(ctx, route); err != nil { + t.Fatalf("route offset %d: %v", offset, err) + } + wait("Caret review probe", offset) + } + waitLine := func(line string) { + t.Helper() + for { + select { + case event := <-adapter.Events(): + if event.Source.ID() == s.ID() && event.Source.BrailleLine == line { + return + } + case <-ctx.Done(): + t.Fatal("edited braille line was not captured") + } + } + } + pressTreeProbeKey(t, "z") + waitLine("Caret z猫 text") + if err := adapter.RouteBraille(ctx, route); !errors.Is(err, ErrStaleObject) { + t.Fatalf("stale text route: %v", err) + } + pressTreeProbeKey(t, "BackSpace") + waitLine("Caret 猫 text") + pressTreeProbeKey(t, "F7") + secret := wait("Protected text probe", -1) + if secret.TextCaretKnown || secret.BrailleLine != "" { + t.Fatal("captured protected braille text") + } + if err := adapter.RouteBraille(ctx, route); !errors.Is(err, ErrStaleObject) { + t.Fatalf("old focus route: %v", err) + } + pressTreeProbeKey(t, "F8") + wait("Numeric value probe", -1) +} diff --git a/internal/atspi/browser_native_test.go b/internal/atspi/browser_native_test.go new file mode 100644 index 0000000..141b4f7 --- /dev/null +++ b/internal/atspi/browser_native_test.go @@ -0,0 +1,483 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeBrowserLiveRegion(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stop, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := adapter.Stop(stop); err != nil { + t.Error(err) + } + }() + window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output() + if err != nil || len(strings.Fields(string(window))) == 0 { + t.Fatalf("browser window: %s: %v", window, err) + } + if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Fields(string(window))[0]).CombinedOutput(); err != nil { + t.Fatalf("focus: %s: %v", out, err) + } + if out, err := exec.CommandContext(ctx, "xdotool", "key", "F9").CombinedOutput(); err != nil { + t.Fatalf("trigger live region: %s: %v", out, err) + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for { + select { + case event := <-adapter.Events(): + if event.Type != ChildrenAddedEvent && event.Type != TextInsertedEvent { + continue + } + t.Logf("update type=%s role=%s live=%t politeness=%q text=%q payload=%q", event.Type, event.Source.Role, event.Source.LiveRegion, event.Source.LivePoliteness, event.Source.Text, event.Payload) + if event.Source.LiveRegion && event.Source.LivePoliteness == "polite" && + (strings.Contains(event.Source.Text, "Username: Test message") || strings.Contains(event.Payload, "Username: Test message")) { + return + } + case <-deadline.C: + t.Fatal("active browser live-region update was not captured") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } +} + +func TestNativeBrowser(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 35*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) + } + }() + // Session services can map windows after the launcher first focuses Brave. + window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output() + if err != nil { + t.Fatal(err) + } + windows := strings.Fields(string(window)) + if len(windows) == 0 { + t.Fatal("browser window missing") + } + if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", windows[0]).CombinedOutput(); err != nil { + t.Fatalf("focus: %s: %v", out, err) + } + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Browser foundation", "Reading section", "Example link", "Display name", "Secret", "Remember choice", "Details"} { + ref := findNamedReference(t, tree, name) + snapshot, err := tree.Resolve(ref) + if err != nil { + t.Fatal(err) + } + t.Logf("%s: role=%s text=%q", name, snapshot.Role, snapshot.Text) + } + + heading, err := tree.Resolve(findNamedReference(t, tree, "Reading section")) + if err != nil || heading.HeadingLevel != 2 { + t.Fatalf("heading level=%d err=%v", heading.HeadingLevel, err) + } + required, err := tree.Resolve(findNamedReference(t, tree, "Display name")) + if err != nil || !required.Required || !required.Editable { + t.Fatalf("entry semantics: %+v %v", required, err) + } + + for _, tc := range []struct { + name string + focusable, popup bool + }{ + {"Reading section", true, false}, + {"Ordinary unfocusable heading", false, false}, + {"Display name", true, false}, + {"Details", true, false}, + {"Popup actions", true, true}, + {"Popup toggle", true, true}, + {"Ordinary action", true, false}, + } { + snapshot, err := tree.Resolve(findNamedReference(t, tree, tc.name)) + if err != nil || !snapshot.StatesKnown || snapshot.Focusable != tc.focusable || snapshot.HasPopup != tc.popup { + t.Fatalf("native mode states for %s: %+v, %v", tc.name, snapshot, err) + } + } + pageRef := findNamedReference(t, tree, "Browser foundation") + pageContext, err := tree.DocumentContext(pageRef) + if err != nil { + t.Fatal(err) + } + embedded := findNamedReference(t, tree, "Embedded heading") + embeddedContext, err := tree.DocumentContext(embedded) + if err != nil { + t.Fatal(err) + } + ordinary := findNamedReference(t, tree, "Ordinary unfocusable heading") + ordinaryContext, err := tree.DocumentContext(ordinary) + if err != nil { + t.Fatal(err) + } + if embeddedContext.TopLevel != pageContext.TopLevel || embeddedContext.Document == pageContext.Document || + ordinaryContext.Document != pageContext.Document { + t.Fatalf("document ownership: embedded=%+v ordinary=%+v page=%+v", embeddedContext, ordinaryContext, pageContext) + } + chromeRef := findNamedReference(t, tree, "Address and search bar") + if _, err := tree.DocumentContext(chromeRef); err != ErrNotInDocument { + t.Fatalf("browser chrome belongs to document: %v", err) + } + // Browser chrome has a version-dependent number of Tab stops. Locate the + // first page heading with a bounded real-key traversal, then verify order. + expected := []string{"Browser foundation", "Reading section", "Example link", "Display name", "Secret", "Remember choice", "Details"} + next := 0 + for attempts := 0; attempts < 35 && next < len(expected); attempts++ { + if out, err := exec.CommandContext(ctx, "xdotool", "key", "Tab").CombinedOutput(); err != nil { + t.Fatalf("keys: %s: %v", out, err) + } + timer := time.NewTimer(500 * time.Millisecond) + events: + for { + select { + case event := <-adapter.Events(): + if event.Type != "focus:" && event.Type != "object:state-changed:focused" { + continue + } + if event.Source.Name == expected[next] { + if !event.Source.StatesKnown || !event.Source.Focusable || !event.Source.Focused { + t.Fatalf("native focus states missing: %+v", event.Source) + } + if event.Source.Name == "Secret" && (event.Source.Text != "" || event.Source.CaretCharacter != "" || event.Source.TextBeforeCaret != "") { + t.Fatal("password snapshot contains text") + } + t.Logf("focus %s: %s", event.Source.Name, event.Source.Role) + next++ + timer.Stop() + break events + } + if next > 0 { + t.Fatalf("focus %q; expected %q", event.Source.Name, expected[next]) + } + case <-timer.C: + break events + case <-ctx.Done(): + timer.Stop() + t.Fatal(ctx.Err()) + } + } + } + if next != len(expected) { + t.Fatalf("only reached %d of %d page controls", next, len(expected)) + } + waitEvent := func(name, kind string, state func(Snapshot) bool) { + t.Helper() + timeout := time.NewTimer(3 * time.Second) + defer timeout.Stop() + for { + select { + case event := <-adapter.Events(): + if event.Source.Name == name && event.Type == kind && state(event.Source) { + return + } + case <-timeout.C: + t.Fatalf("missing %s event for %s", kind, name) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + key := func(keys ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", append([]string{"key"}, keys...)...).CombinedOutput(); err != nil { + t.Fatalf("keys: %s: %v", out, err) + } + } + key("space") + waitEvent("Details", "object:state-changed:expanded", func(s Snapshot) bool { return s.Expanded }) + key("F8") + waitEvent("Cthulhu Browser Probe updated", AccessibleNameEvent, func(s Snapshot) bool { + if s.ToolkitName != "Chromium" { + t.Fatalf("browser name event toolkit = %q", s.ToolkitName) + } + return true + }) + key("shift+Tab") + waitEvent("Remember choice", "object:state-changed:focused", func(s Snapshot) bool { return true }) + key("space") + waitEvent("Remember choice", "object:state-changed:checked", func(s Snapshot) bool { return s.Checked }) + // Move between page controls before checking focus loss. Chromium can keep + // the DOM's focused state when its address bar owns desktop focus. + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + previous, err := tree.Resolve(findNamedReference(t, tree, "Details")) + if err != nil || !previous.StatesKnown || previous.Focused { + t.Fatalf("recapture retained old focused state: %+v, %v", previous, err) + } + key("ctrl+l") + // Recapture after scrolling/focus events before resolving chrome ownership. + waitEvent("Address and search bar", "object:state-changed:focused", func(s Snapshot) bool { return s.Editable }) + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + chrome := findNamedReference(t, tree, "Address and search bar") + if _, err := tree.DocumentContext(chrome); err != ErrNotInDocument { + t.Fatalf("address bar document: %v", err) + } + foundInsertion, foundPopup := false, false + key("c") + timerTyping := time.NewTimer(250 * time.Millisecond) +readTyping: + for { + select { + case event := <-adapter.Events(): + if event.Type == TextInsertedEvent && event.Payload == "c" && event.Source.Editable && event.Source.Autocomplete { + foundInsertion = true + } + if event.Source.Role == "list item" && event.Source.AutocompletePopup && event.PopupFor == chrome.ID() { + foundPopup = true + } + case <-timerTyping.C: + break readTyping + } + } + if !foundInsertion || !foundPopup { + t.Fatalf("address semantics: insertion=%v popup=%v", foundInsertion, foundPopup) + } + key("Escape", "Escape", "Escape") + waitEvent("Remember choice", "object:state-changed:focused", func(s Snapshot) bool { return s.Checked }) + pageRef = findNamedReference(t, tree, "Browser foundation") + key("ctrl+r") + loadTimer := time.NewTimer(5 * time.Second) + defer loadTimer.Stop() +waitLoad: + for { + select { + case event := <-adapter.Events(): + if event.Type == DocumentLoadCompleteEvent { + if event.Source.Name != "Cthulhu Browser Probe" || event.Source.Text != "" { + t.Fatalf("loaded title snapshot: %+v", event.Source) + } + break waitLoad + } + case <-loadTimer.C: + t.Fatal("active document load announcement missing") + } + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() +waitReload: + for { + select { + case <-ticker.C: + if _, err := tree.Resolve(pageRef); err != nil { + break waitReload + } + case <-deadline.C: + t.Fatal("browser reload retained old document reference") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + // A fresh document must be discoverable after the adapter has already + // cached the browser tree, not only when connecting to a preloaded page. + for attempts := 0; attempts < 25; attempts++ { + key("Tab") + timer := time.NewTimer(250 * time.Millisecond) + waitFocus: + for { + select { + case event := <-adapter.Events(): + if event.Type == FocusedStateEvent && event.Source.Name == "Browser foundation" { + timer.Stop() + captureCtx, stopCapture := context.WithTimeout(ctx, 2*time.Second) + captured, err := adapter.CaptureDocument(captureCtx, event.Source.ID()) + stopCapture() + if err != nil { + t.Fatal(err) + } + ref, err := captured.Reference(event.Source.ID()) + if err != nil { + t.Fatal(err) + } + doc, err := captured.Document(ctx, ref) + if err != nil { + t.Fatal(err) + } + foundFragment := false + for _, node := range doc.Nodes { + if node.Snapshot.ElementID == "target" { + foundFragment = true + break + } + } + if !foundFragment { + t.Fatal("document capture omitted the target element ID") + } + root, err := captured.Root() + if err != nil || root != doc.Context.TopLevel || captured == tree { + t.Fatalf("document capture must own only the document: root=%v error=%v", root, err) + } + link := findNamedReference(t, captured, "Example link") + if err := adapter.ActivateDocumentObject(ctx, link, event.Source.ID()); err != nil { + t.Fatalf("activate captured link: %v", err) + } + activated := false + for attempts := 0; attempts < 20 && !activated; attempts++ { + updated, err := adapter.CaptureDocument(ctx, link.ID()) + if err == nil { + root, rootErr := updated.Root() + if rootErr == nil { + document, documentErr := updated.Document(ctx, root) + if documentErr == nil { + for _, node := range document.Nodes { + if node.Snapshot.Name == "Text refreshed after the ordinary heading." { + activated = true + break + } + } + } + } + } + if !activated { + time.Sleep(50 * time.Millisecond) + } + } + if !activated { + t.Fatal("captured link action did not update the page") + } + testNativeBrowserBackgroundLoad(t, adapter) + return + } + case <-timer.C: + break waitFocus + case <-ctx.Done(): + timer.Stop() + t.Fatal(ctx.Err()) + } + } + } + t.Fatal("reloaded heading focus missing") +} + +func testNativeBrowserBackgroundLoad(t *testing.T, adapter *Adapter) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + requested, release := make(chan struct{}, 1), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/hold" { + select { + case requested <- struct{}{}: + default: + } + select { + case <-release: + case <-r.Context().Done(): + } + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`Pending page

Content

Pending image`)) + })) + defer func() { + select { + case <-release: + default: + close(release) + } + server.Close() + }() + key := func(chord string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys: %s: %v", out, err) + } + } + key("ctrl+l") + if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", server.URL).CombinedOutput(); err != nil { + t.Fatalf("type local URL: %s: %v", out, err) + } + key("Return") + select { + case <-requested: + case <-ctx.Done(): + t.Fatal("page did not request its held resource") + } + // Discard focus events from navigating to the held page. Only a fresh + // address focus after Ctrl+L can release the response and complete loading. + for len(adapter.events) > 0 { + <-adapter.events + } + key("ctrl+l") + var chrome ObjectID + for !chrome.Valid() { + select { + case event := <-adapter.Events(): + if event.Type == FocusedStateEvent && event.Source.Autocomplete && event.Source.Focused { + chrome = event.Source.ID() + } + case <-ctx.Done(): + t.Fatal("address focus missing during page load") + } + } + close(release) + for { + select { + case event := <-adapter.Events(): + if event.Type == DocumentLoadCompleteEvent && event.Source.Name == "Pending page" { + if event.FocusOutsideDocument != chrome { + t.Fatalf("load lost native chrome focus: got %v want %v", event.FocusOutsideDocument, chrome) + } + return + } + case <-ctx.Done(): + t.Fatal("background load event missing") + } + } +} diff --git a/internal/atspi/catalog_native_test.go b/internal/atspi/catalog_native_test.go new file mode 100644 index 0000000..108e8f6 --- /dev/null +++ b/internal/atspi/catalog_native_test.go @@ -0,0 +1,99 @@ +// 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" + "strings" + "testing" + "time" +) + +func TestNativeBrowserCatalogCapture(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := New() + adapter.SetCaptureSoundGeometry(true) + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + // Move away from the autofocus control and back to obtain a fresh event. + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil { + t.Fatalf("focus catalog: %v %s", err, out) + } + var focus ObjectID + for !focus.Valid() { + select { + case event := <-adapter.Events(): + if event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Choose language" { + focus = event.Source.ID() + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + started := time.Now() + captureContext, captureCancel := context.WithTimeout(ctx, 2*time.Second) + tree, err := adapter.CaptureDocument(captureContext, focus) + captureCancel() + elapsed := time.Since(started) + if err != nil { + t.Fatal(err) + } + root, _ := tree.Root() + document, err := tree.Document(ctx, root) + if err != nil { + t.Fatal(err) + } + t.Logf("cold catalog capture: %v, %d nodes", elapsed, len(document.Nodes)) + if len(document.Nodes) < 4800 { + t.Fatalf("catalog capture lost nodes: %d", len(document.Nodes)) + } + lastHeading := findNamedReference(t, tree, "Movie 599") + line, err := adapter.ReadDocumentCaret(ctx, lastHeading, 0, DocumentCaretBrailleLine) + if err != nil || strings.TrimSpace(line.Text) != "Movie 599" { + t.Fatalf("last catalog heading text = %+v, %v", line, err) + } + focused, err := tree.Resolve(findNamedReference(t, tree, "Choose language")) + if err != nil || !focused.SoundGeometry.ObjectKnown || !focused.SoundGeometry.ReferenceKnown { + t.Fatalf("catalog control sound geometry = %+v, %v", focused.SoundGeometry, err) + } + if elapsed >= 2*time.Second { + t.Fatalf("cold catalog capture exceeded runtime budget: %v", elapsed) + } + // Dirty semantics/children must refresh even though cold nodes reuse the + // native event cache. Removed objects must not survive the refresh. + if out, err := exec.CommandContext(ctx, "xdotool", "key", "F9").CombinedOutput(); err != nil { + t.Fatalf("update catalog: %v %s", err, out) + } + for { + select { + case event := <-adapter.Events(): + if event.Type != AccessibleNameEvent || event.Source.Name != "Catalog updated" { + continue + } + captureContext, captureCancel = context.WithTimeout(ctx, 2*time.Second) + tree, err = adapter.CaptureDocument(captureContext, focus) + captureCancel() + if err != nil { + t.Fatal(err) + } + findNamedReference(t, tree, "Updated movie") + findNamedReference(t, tree, "New movie") + if _, err := tree.Resolve(lastHeading); err == nil { + t.Fatal("removed catalog heading remained live") + } + return + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } +} diff --git a/internal/atspi/character_attributes_native.go b/internal/atspi/character_attributes_native.go new file mode 100644 index 0000000..31b46b5 --- /dev/null +++ b/internal/atspi/character_attributes_native.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "unsafe" +) + +// CharacterAttributes is a copy of the current AT-SPI text attribute run. +// Native handles remain on the GLib thread. +type CharacterAttributes struct { + Object ObjectID + Values map[string]string +} + +var spokenCharacterAttributeKeys = [...]string{ + "size", "family-name", "weight", "indent", "underline", + "strikethrough", "justification", "style", "paragraph-style", + "text-spelling", "fg-color", "bg-color", "invalid", "invalid-spelling", "spelling", +} + +func (adapter *Adapter) ReadCharacterAttributes(ctx context.Context, object ObjectID) (CharacterAttributes, error) { + if !object.Valid() { + return CharacterAttributes{}, ErrStaleObject + } + result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: object, readAttributes: true}) + return result.attributes, err +} + +func (adapter *Adapter) readNativeCharacterAttributes(ctx context.Context, object ObjectID) (CharacterAttributes, error) { + if err := ctx.Err(); err != nil { + return CharacterAttributes{}, err + } + if len(adapter.nativeFocus) == 0 || adapter.nativeFocus[len(adapter.nativeFocus)-1].id.BusName != object.BusName { + return CharacterAttributes{}, ErrStaleObject + } + var source *C.AtspiAccessible + if adapter.nativeFocusIs(object) { + source = adapter.nativeFocus[len(adapter.nativeFocus)-1].source + } else { + source = adapter.nativeDocument[object] + } + if source == nil { + return CharacterAttributes{}, ErrStaleObject + } + if C.atspi_accessible_get_role(source, nil) == C.ATSPI_ROLE_PASSWORD_TEXT { + return CharacterAttributes{Object: object}, nil + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return CharacterAttributes{Object: object}, nil + } + defer C.g_object_unref(C.gpointer(text)) + var nativeErr *C.GError + count := C.atspi_text_get_character_count(text, &nativeErr) + if nativeErr != nil { + err := nativeError("read character attribute text length", nativeErr) + C.g_error_free(nativeErr) + return CharacterAttributes{}, err + } + if count <= 0 { + return CharacterAttributes{Object: object}, nil + } + offset := C.atspi_text_get_caret_offset(text, &nativeErr) + if nativeErr != nil { + err := nativeError("read character attribute caret", nativeErr) + C.g_error_free(nativeErr) + return CharacterAttributes{}, err + } + if offset < 0 || offset >= count { + offset = count - 1 + } + var start, end C.gint + attributes := C.atspi_text_get_attribute_run(text, offset, 1, &start, &end, &nativeErr) + if nativeErr != nil { + err := nativeError("read character attributes", nativeErr) + C.g_error_free(nativeErr) + return CharacterAttributes{}, err + } + if attributes == nil { + return CharacterAttributes{Object: object}, nil + } + defer C.g_hash_table_unref(attributes) + result := CharacterAttributes{Object: object, Values: make(map[string]string)} + for _, key := range spokenCharacterAttributeKeys { + name := C.CString(key) + value := C.g_hash_table_lookup(attributes, C.gconstpointer(unsafe.Pointer(name))) + C.free(unsafe.Pointer(name)) + if value != nil { + result.Values[key] = sanitizeSnapshotField(C.GoString((*C.char)(value))) + } + } + return result, ctx.Err() +} diff --git a/internal/atspi/document.go b/internal/atspi/document.go new file mode 100644 index 0000000..c145cac --- /dev/null +++ b/internal/atspi/document.go @@ -0,0 +1,158 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" +) + +var ErrNotInDocument = errors.New("accessible is outside web document content") + +// DocumentContext identifies both the innermost document and the outermost +// document containing an object. Ancestors run from the object to the tree +// root. All data is copied under one tree lock; no native references escape. +type DocumentContext struct { + Document ObjectReference + TopLevel ObjectReference + Ancestors []Snapshot +} + +// IsWebDocumentRole follows the shared Python web script's document roles. +// Generic text/spreadsheet documents must not enable browser key handling. +func IsWebDocumentRole(role string) bool { + return role == "document web" || role == "document frame" || role == "embedded" +} + +func (tree *AccessibleTree) DocumentContext(reference ObjectReference) (DocumentContext, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + return tree.documentContextLocked(reference) +} + +func (tree *AccessibleTree) documentContextLocked(reference ObjectReference) (DocumentContext, error) { + entry, err := tree.resolveLocked(reference) + if err != nil { + return DocumentContext{}, err + } + var result DocumentContext + for { + if entry.snapshotInvalid { + return DocumentContext{}, ErrStaleSnapshot + } + result.Ancestors = append(result.Ancestors, entry.snapshot) + if IsWebDocumentRole(entry.snapshot.Role) { + if !result.Document.Valid() { + result.Document = reference + } + result.TopLevel = reference + } + if !entry.hasParent { + break + } + id := entry.parent + entry = tree.nodes[id] + reference = newObjectReference(id, entry) + } + if !result.Document.Valid() { + return DocumentContext{}, ErrNotInDocument + } + return result, nil +} + +// DocumentNode is one owned semantic object in document tree order. Visible +// does not mean on-screen: document navigation must include offscreen content. +type DocumentNode struct { + Reference ObjectReference + Parent ObjectID + Snapshot Snapshot + Geometry Geometry + Text VisibleText + Hidden bool + // ParentEditable is captured with the node so entry matching stays + // constant-time instead of rescanning the complete document per candidate. + ParentEditable bool + Children []ObjectID +} + +type DocumentSnapshot struct { + Context DocumentContext + Nodes []DocumentNode +} + +// Document copies the top-level document, including embedded documents but +// excluding browser chrome and other tabs. A stale/incomplete capture fails +// atomically. It does not use flat review's viewport-limited text ranges. +func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + if err := ctx.Err(); err != nil { + return DocumentSnapshot{}, err + } + owner, err := tree.documentContextLocked(reference) + if err != nil { + return DocumentSnapshot{}, err + } + result := DocumentSnapshot{Context: owner} + type pendingNode struct { + id ObjectID + hidden bool + } + pending := []pendingNode{{id: owner.TopLevel.ID()}} + for len(pending) > 0 { + if err := ctx.Err(); err != nil { + return DocumentSnapshot{}, err + } + last := len(pending) - 1 + next := pending[last] + pending = pending[:last] + entry := tree.nodes[next.id] + if entry.snapshotInvalid { + return DocumentSnapshot{}, ErrStaleSnapshot + } + if entry.childrenInvalid { + return DocumentSnapshot{}, ErrIncompleteTree + } + if entry.textInvalid { + return DocumentSnapshot{}, ErrStaleText + } + if entry.geometryInvalid { + return DocumentSnapshot{}, ErrStaleGeometry + } + hidden := next.hidden || entry.geometry.VisibilityKnown && !entry.geometry.Visible + parentEditable := false + if entry.hasParent { + parentEditable = tree.nodes[entry.parent].snapshot.Editable + } + result.Nodes = append(result.Nodes, DocumentNode{ + Parent: entry.parent, + Reference: newObjectReference(next.id, entry), + Snapshot: entry.snapshot, + Geometry: entry.geometry, + Text: entry.text, + Hidden: hidden, + ParentEditable: parentEditable, + Children: entry.children, + }) + for i := len(entry.children) - 1; i >= 0; i-- { + pending = append(pending, pendingNode{entry.children[i], hidden}) + } + } + populate_document_rows(&result) + return result, nil +} diff --git a/internal/atspi/document_caret.go b/internal/atspi/document_caret.go new file mode 100644 index 0000000..bda26b5 --- /dev/null +++ b/internal/atspi/document_caret.go @@ -0,0 +1,40 @@ +package atspi + +import "unicode/utf8" + +// DocumentCaretUnit selects the AT-SPI text granularity for virtual browsing. +type DocumentCaretUnit uint8 + +const ( + DocumentCaretCharacter DocumentCaretUnit = iota + 1 + DocumentCaretWord + DocumentCaretLine + DocumentCaretLineRemainder + // DocumentCaretBrailleLine preserves native spacing for display offsets. + DocumentCaretBrailleLine +) + +func (unit DocumentCaretUnit) Valid() bool { + return unit >= DocumentCaretCharacter && unit <= DocumentCaretBrailleLine +} + +type DocumentCaretText struct { + TextAttributeRuns []TextAttributeRun + Start int + End int + Text string + RawText string + Misspelled bool + CharacterCount int + SelectionStart, SelectionEnd int + // Bounds describe this native line, including offscreen document text. + Bounds Rectangle +} + +func documentCaretCharacter(value string) string { + if value == "" { + return "" + } + character, _ := utf8.DecodeRuneInString(value) + return string(character) +} diff --git a/internal/atspi/document_caret_echo.go b/internal/atspi/document_caret_echo.go new file mode 100644 index 0000000..8055352 --- /dev/null +++ b/internal/atspi/document_caret_echo.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +// Chromium can echo a static text caret on its enclosing label/paragraph. +// Include only a single-child chain sharing the complete text offset domain. +// Composite parents have their own hypertext offsets and cannot be matched. +func documentCaretEchoAncestors(tree *AccessibleTree, reference ObjectReference) []ObjectID { + var ids []ObjectID + child, err := tree.Resolve(reference) + if err != nil || child.CharacterCount <= 0 { + return nil + } + for attempts := 0; attempts < maxTreeNodes; attempts++ { + parent, ok, err := tree.Parent(reference) + if err != nil || !ok { + break + } + snapshot, err := tree.Resolve(parent) + if err != nil || IsWebDocumentRole(snapshot.Role) || snapshot.Editable || snapshot.CharacterCount != child.CharacterCount { + break + } + first, ok, err := tree.FirstChild(parent) + if err != nil || !ok || first != reference { + break + } + last, ok, err := tree.LastChild(parent) + if err != nil || !ok || last != reference { + break + } + ids = append(ids, parent.ID()) + reference, child = parent, snapshot + } + return ids +} diff --git a/internal/atspi/document_caret_test.go b/internal/atspi/document_caret_test.go new file mode 100644 index 0000000..596594c --- /dev/null +++ b/internal/atspi/document_caret_test.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import "testing" + +func TestDocumentCaretCharacterPreservesDelimiters(t *testing.T) { + for _, value := range []string{"", " ", "\t", "\n", "\r", "\u00a0", "猫", "é", "\ufffc"} { + if got := documentCaretCharacter(value); got != value { + t.Errorf("character %q became %q", value, got) + } + } +} diff --git a/internal/atspi/document_line.go b/internal/atspi/document_line.go new file mode 100644 index 0000000..2a6a353 --- /dev/null +++ b/internal/atspi/document_line.go @@ -0,0 +1,131 @@ +package atspi + +import ( + "context" + "errors" + "math" +) + +var ErrNoDocumentCaret = errors.New("document caret line unavailable") + +type DocumentContent struct { + Reference ObjectReference + Snapshot Snapshot + Start int + End int + Text string + RawText string + // Separator is display spacing before this object, outside its native range. + Separator string + Bounds Rectangle +} + +type DocumentLine struct { + Context []Snapshot + Document ObjectReference + Contents []DocumentContent +} + +func (tree *AccessibleTree) DocumentLineAtOffset(ctx context.Context, reference ObjectReference, offset int) (DocumentLine, error) { + document, err := tree.Document(ctx, reference) + if err != nil { + return DocumentLine{}, err + } + + caretIndex := -1 + if reference.Valid() { + for index, node := range document.Nodes { + if node.Reference == reference { + caretIndex = index + break + } + } + } + if caretIndex < 0 { + for index, node := range document.Nodes { + if node.Text.CaretKnown { + caretIndex = index + break + } + } + } + if caretIndex < 0 { + return DocumentLine{}, ErrNoDocumentCaret + } + + caretNode := document.Nodes[caretIndex] + if offset < 0 { + offset = caretNode.Text.CaretOffset + } + if offset < 0 { + return DocumentLine{}, ErrNoDocumentCaret + } + + caretRange, found := textRangeAtOffset(caretNode.Text, offset) + if !found || !caretRange.Bounds.Valid() { + return DocumentLine{}, ErrNoDocumentCaret + } + + ancestors := document.Context.Ancestors + if len(ancestors) > 0 && ancestors[0].ID() == reference.ID() { + ancestors = ancestors[1:] + } + line := DocumentLine{Document: document.Context.TopLevel, Context: ancestors} + for _, node := range document.Nodes { + if node.Hidden { + continue + } + for _, textRange := range node.Text.Ranges { + if !boundsAreOnSameLine(caretRange.Bounds, textRange.Bounds) { + continue + } + if textRange.Text == "" { + continue + } + line.Contents = append(line.Contents, DocumentContent{ + Reference: node.Reference, + Snapshot: node.Snapshot, + Start: textRange.Start, + End: textRange.End, + Text: textRange.Text, + RawText: textRange.Text, + Bounds: textRange.Bounds, + }) + } + } + if len(line.Contents) == 0 { + return DocumentLine{}, ErrNoDocumentCaret + } + return line, nil +} + +func textRangeAtOffset(text VisibleText, offset int) (TextRange, bool) { + for _, textRange := range text.Ranges { + if textRange.Start <= offset && offset < textRange.End { + return textRange, true + } + } + for _, textRange := range text.Ranges { + if textRange.Start <= offset && offset <= textRange.End { + return textRange, true + } + } + return TextRange{}, false +} + +func boundsAreOnSameLine(first, second Rectangle) bool { + if first == second { + return true + } + if !first.Valid() || !second.Valid() { + return false + } + top := max(first.Y, second.Y) + bottom := min(first.Y+first.Height, second.Y+second.Height) + if bottom <= top { + return false + } + firstCenter := first.Y + first.Height/2 + secondCenter := second.Y + second.Height/2 + return math.Abs(float64(firstCenter-secondCenter)) <= 5 +} diff --git a/internal/atspi/document_lines_native.go b/internal/atspi/document_lines_native.go new file mode 100644 index 0000000..eaceb0e --- /dev/null +++ b/internal/atspi/document_lines_native.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include +*/ +import "C" + +import ( + "context" + "strings" + "time" + "unicode/utf8" +) + +func (adapter *Adapter) readNativeDocumentLine(ctx context.Context, id ObjectID, snapshot Snapshot, text *C.AtspiText, offset, count int) (DocumentCaretText, error) { + source := nativeReviewText{text: text} + line, err := source.line(ctx, offset) + if err != nil { + return DocumentCaretText{}, err + } + result := DocumentCaretText{Start: line.Start, End: line.End, Text: line.Text, CharacterCount: count} + if snapshot.Editable || snapshot.Role == "password text" || snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" || count > maxReviewLineCharacters || + len(strings.Fields(line.Text)) > 1 || line.Start == 0 && line.End == count { + return result, nil + } + if cached, ok := adapter.documentLineCache[id]; ok && cached.CharacterCount == count { + if cached.End > 0 { + return cached, nil + } + return result, nil + } + adapter.treeMutex.Lock() + tree := adapter.documentTree + adapter.treeMutex.Unlock() + if tree != nil { + if ref, err := tree.Reference(id); err == nil { + if owner, err := tree.DocumentContext(ref); err == nil { + for _, ancestor := range owner.Ancestors { + if ancestor.HTMLTag == "pre" || ancestor.HTMLTag == "code" || strings.Contains(" "+ancestor.XMLRoles+" ", " code ") { + return result, nil + } + } + } + } + } + var nativeErr *C.GError + value := C.atspi_text_get_text(text, 0, C.gint(count), &nativeErr) + if value != nil { + defer C.g_free(C.gpointer(value)) + } + if nativeErr != nil { + C.g_error_free(nativeErr) + return result, nil + } + content := strings.ToValidUTF8(C.GoString(value), "�") + if utf8.RuneCountInString(content) != count { + return result, nil + } + probe, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + repair, probeErr := documentLinesAreTokens(probe, source, content) + cancel() + if probeErr != nil { + return result, ctx.Err() + } + if adapter.documentLineCache == nil || len(adapter.documentLineCache) >= 64 { + adapter.documentLineCache = make(map[ObjectID]DocumentCaretText) + } + cached := DocumentCaretText{CharacterCount: count} + if repair { + cached.End, cached.Text = count, content + result = cached + } + adapter.documentLineCache[id] = cached + return result, nil +} + +// Repair only owned web text, on the GLib owner. Native selections use the +// same logical line as virtual browsing, including braille cursor placement. +func (adapter *Adapter) repairNativeCaretLine(source *C.AtspiAccessible, snapshot *Snapshot, offset int) { + if !snapshot.TextCaretKnown || snapshot.Editable || snapshot.Role == "password text" || offset < 0 { + return + } + adapter.treeMutex.Lock() + tree := adapter.documentTree + adapter.treeMutex.Unlock() + if tree == nil { + return + } + ref, err := tree.Reference(snapshot.ID()) + if err != nil { + return + } + if _, err = tree.DocumentContext(ref); err != nil { + return + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return + } + defer C.g_object_unref(C.gpointer(text)) + count := int(C.atspi_text_get_character_count(text, nil)) + if count <= 0 || offset > count { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + line, err := adapter.readNativeDocumentLine(ctx, snapshot.ID(), *snapshot, text, min(offset, count-1), count) + if err != nil || line.Start < 0 || line.End <= line.Start { + return + } + snapshot.Text = sanitizeSnapshotField(line.Text) + snapshot.BrailleLine, snapshot.BrailleLineStart, snapshot.BrailleCaret = line.Text, line.Start, offset + snapshot.CharacterCount = count +} diff --git a/internal/atspi/document_lines_native_test.go b/internal/atspi/document_lines_native_test.go new file mode 100644 index 0000000..516d9a5 --- /dev/null +++ b/internal/atspi/document_lines_native_test.go @@ -0,0 +1,65 @@ +// 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" + "strings" + "testing" + "time" +) + +func TestNativeBrowserLogicalLines(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 adapter.Stop(context.Background()) + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil { + t.Fatalf("focus editable lines: %v %s", err, out) + } + var focus ObjectID + for !focus.Valid() { + select { + case event := <-adapter.Events(): + if (event.Type == FocusEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Editable lines" { + focus = event.Source.ID() + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + tree, err := adapter.CaptureDocument(ctx, focus) + if err != nil { + t.Fatal(err) + } + text := findNamedReference(t, tree, "What's on your mind?") + for _, offset := range []int{0, 7, 10, 15, 20} { + line, err := adapter.ReadDocumentCaret(ctx, text, offset, DocumentCaretBrailleLine) + if err != nil || line.Start != 0 || line.End != 20 || line.Text != "What's on your mind?" { + t.Fatalf("line at %d = %+v, %v", offset, line, err) + } + } + remainder, err := adapter.ReadDocumentCaret(ctx, text, 7, DocumentCaretLineRemainder) + if err != nil || remainder.Start != 7 || remainder.Text != "on your mind?" { + t.Fatalf("remainder = %+v, %v", remainder, err) + } + for _, test := range []struct{ name, first string }{ + {"First Second", "First"}, {"Code text", "Code"}, {"Editable lines", "First"}, + } { + ref := findNamedReference(t, tree, test.name) + line, err := adapter.ReadDocumentCaret(ctx, ref, 0, DocumentCaretBrailleLine) + if err != nil || strings.TrimSpace(line.Text) != test.first { + t.Fatalf("preserved %s line = %+v, %v", test.name, line, err) + } + } +} diff --git a/internal/atspi/document_rows.go b/internal/atspi/document_rows.go new file mode 100644 index 0000000..e6bd03c --- /dev/null +++ b/internal/atspi/document_rows.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "sort" + "strings" +) + +// Derive rows from the already captured document. This performs no native +// queries and keeps large tables bounded to 32 cells and 4096 bytes per row. +func populate_document_rows(document *DocumentSnapshot) { + nodes := make(map[ObjectID]int, len(document.Nodes)) + for index, node := range document.Nodes { + nodes[node.Reference.ID()] = index + } + type rowKey struct { + table ObjectID + row int + } + rows := make(map[rowKey][]int) + owners := make(map[int]rowKey) + for index, node := range document.Nodes { + if node.Hidden || !node.Snapshot.TablePositionKnown || node.Snapshot.Role != "table cell" { + continue + } + current := node.Parent + var owner ObjectID + spreadsheet := false + for depth := 0; depth < 32 && current.Valid(); depth++ { + parentIndex, exists := nodes[current] + if !exists { + break + } + parent := document.Nodes[parentIndex] + if owner == (ObjectID{}) && parent.Snapshot.Role == "table" { + owner = current + } + if parent.Snapshot.Role == "document spreadsheet" { + spreadsheet = true + } + current = parent.Parent + } + if !owner.Valid() { + continue + } + document.Nodes[index].Snapshot.Spreadsheet = spreadsheet + document.Nodes[index].Snapshot.TablePath = owner.Path + key := rowKey{owner, node.Snapshot.TableRowIndex} + if len(rows[key]) < 32 { + rows[key] = append(rows[key], index) + } + owners[index] = key + } + texts := make(map[rowKey]string, len(rows)) + for key, indices := range rows { + sort.SliceStable(indices, func(a, b int) bool { + return document.Nodes[indices[a]].Snapshot.TableColumnIndex < document.Nodes[indices[b]].Snapshot.TableColumnIndex + }) + var parts []string + length := 0 + for _, index := range indices { + snapshot := document.Nodes[index].Snapshot + label := strings.TrimSpace(snapshot.Name) + if label == "" { + label = strings.TrimSpace(snapshot.Text) + } + if label == "" { + label = "blank" + } + if length+len(label)+2 > 4096 { + break + } + length += len(label) + 2 + parts = append(parts, label) + } + texts[key] = strings.Join(parts, ", ") + } + for index, key := range owners { + document.Nodes[index].Snapshot.TableRowText = texts[key] + } +} diff --git a/internal/atspi/document_rows_test.go b/internal/atspi/document_rows_test.go new file mode 100644 index 0000000..8bb629b --- /dev/null +++ b/internal/atspi/document_rows_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "testing" +) + +func TestDocumentRowsKeepNestedTablesAndRowsSeparate(t *testing.T) { + node := func(path, role string, children ...ObjectID) TreeNode { + n := testNode(path, path, children...) + n.Snapshot.Role = role + return n + } + cell := func(path, label string, row, column int, children ...ObjectID) TreeNode { + n := node(path, "table cell", children...) + n.Snapshot.Name = label + n.Snapshot.TablePositionKnown = true + n.Snapshot.TableRowIndex, n.Snapshot.TableColumnIndex = row, column + return n + } + tree, err := NewAccessibleTree(testID("/doc"), []TreeNode{ + node("/doc", "document web", testID("/sheet")), + node("/sheet", "document spreadsheet", testID("/outer")), + node("/outer", "table", testID("/b"), testID("/a"), testID("/c")), + cell("/b", "Two", 0, 1), + cell("/a", "One", 0, 0, testID("/inner")), + cell("/c", "Next", 1, 0), + node("/inner", "table", testID("/i")), + cell("/i", "Nested", 0, 0), + }) + if err != nil { + t.Fatal(err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + document, err := tree.Document(context.Background(), root) + if err != nil { + t.Fatal(err) + } + wants := map[string]string{"/a": "One, Two", "/b": "One, Two", "/c": "Next", "/i": "Nested"} + for _, item := range document.Nodes { + if want, ok := wants[item.Snapshot.Path]; ok { + if item.Snapshot.TableRowText != want || !item.Snapshot.Spreadsheet { + t.Fatalf("row %s: %+v", item.Snapshot.Path, item.Snapshot) + } + } + } +} diff --git a/internal/atspi/document_test.go b/internal/atspi/document_test.go new file mode 100644 index 0000000..e23462d --- /dev/null +++ b/internal/atspi/document_test.go @@ -0,0 +1,369 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "unicode/utf8" +) + +func documentTestNodes() []TreeNode { + node := func(path, role string, children ...ObjectID) TreeNode { + n := testNode(path, path, children...) + n.Snapshot.Role = role + return n + } + return []TreeNode{ + node("/root", "frame", testID("/chrome"), testID("/doc"), testID("/other")), + node("/chrome", "entry"), + node("/doc", "document web", testID("/heading"), testID("/inner")), + node("/heading", "heading"), + node("/inner", "document frame", testID("/link")), + node("/link", "link"), + node("/other", "document web"), + } +} + +func TestDocumentTargetFocusPolicyForHeadings(t *testing.T) { + nodes := []TreeNode{ + testNode("/doc", "/doc", testID("/ordinary"), testID("/linked"), testID("/button"), testID("/entry"), testID("/image"), testID("/imageLink")), + testNode("/ordinary", "/ordinary", testID("/text")), + testNode("/text", "/text"), + testNode("/linked", "/linked", testID("/link")), + testNode("/link", "/link"), + testNode("/button", "/button"), + testNode("/entry", "/entry"), + testNode("/image", "/image"), + testNode("/imageLink", "/imageLink", testID("/linkedImage")), + testNode("/linkedImage", "/linkedImage"), + } + nodes[0].Snapshot.Role = "document web" + for i, role := range map[int]string{1: "heading", 2: "static", 3: "heading", 4: "link", 5: "button", 6: "entry", 7: "image", 8: "link", 9: "image"} { + nodes[i].Snapshot.Role = role + nodes[i].Snapshot.Focusable = true + } + tree, err := NewAccessibleTree(testID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + path string + want bool + }{ + {"/ordinary", false}, + {"/linked", true}, + {"/button", true}, + {"/entry", false}, + {"/image", false}, + {"/linkedImage", true}, + } { + ref, err := tree.Reference(testID(tc.path)) + if err != nil { + t.Fatal(err) + } + snapshot, err := tree.Resolve(ref) + if err != nil { + t.Fatal(err) + } + if got := documentTargetGrabsFocus(tree, ref, snapshot); got != tc.want { + t.Errorf("focus policy for %s = %v, want %v", tc.path, got, tc.want) + } + } +} + +func TestDocumentOwnershipAndOrder(t *testing.T) { + tree, err := NewAccessibleTree(testID("/root"), documentTestNodes()) + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Reference(testID("/link")) + doc, err := tree.Document(context.Background(), ref) + if err != nil { + t.Fatal(err) + } + if doc.Context.Document.ID() != testID("/inner") || doc.Context.TopLevel.ID() != testID("/doc") { + t.Fatalf("wrong document ownership: %+v", doc.Context) + } + var paths []string + for _, node := range doc.Nodes { + paths = append(paths, node.Snapshot.Path) + } + if !reflect.DeepEqual(paths, []string{"/doc", "/heading", "/inner", "/link"}) { + t.Fatalf("document order: %v", paths) + } + var ancestors []string + for _, node := range doc.Context.Ancestors { + ancestors = append(ancestors, node.Path) + } + if !reflect.DeepEqual(ancestors, []string{"/link", "/inner", "/doc", "/root"}) { + t.Fatalf("ancestry: %v", ancestors) + } + doc.Nodes[0].Snapshot.Name = "mutated" + doc.Context.Ancestors[0].Name = "mutated" + again, err := tree.Document(context.Background(), ref) + if err != nil || again.Nodes[0].Snapshot.Name != "/doc" || again.Context.Ancestors[0].Name != "/link" { + t.Fatalf("snapshot aliasing: %+v %v", again, err) + } + chrome, _ := tree.Reference(testID("/chrome")) + if _, err := tree.DocumentContext(chrome); !errors.Is(err, ErrNotInDocument) { + t.Fatalf("browser chrome ownership: %v", err) + } +} + +func TestDocumentRejectsStaleCapture(t *testing.T) { + for _, tc := range []struct { + name string + invalidate func(*AccessibleTree) + want error + }{ + {"children", func(tree *AccessibleTree) { tree.InvalidateChildren(testID("/inner")) }, ErrIncompleteTree}, + {"text", func(tree *AccessibleTree) { tree.InvalidateText(testID("/link")) }, ErrStaleText}, + {"visibility", func(tree *AccessibleTree) { tree.InvalidateGeometry(testID("/inner")) }, ErrStaleGeometry}, + {"document", func(tree *AccessibleTree) { tree.Invalidate(testID("/doc")) }, ErrStaleObject}, + } { + t.Run(tc.name, func(t *testing.T) { + tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes()) + ref, _ := tree.Reference(testID("/heading")) + tc.invalidate(tree) + doc, err := tree.Document(context.Background(), ref) + if !errors.Is(err, tc.want) || len(doc.Nodes) != 0 { + t.Fatalf("partial/stale document: %+v %v", doc, err) + } + }) + } +} + +func TestDocumentVisibilityAndCancellation(t *testing.T) { + nodes := documentTestNodes() + nodes[3].Geometry = Geometry{VisibilityKnown: true, Visible: true} // Offscreen, still navigable. + nodes[4].Geometry = Geometry{VisibilityKnown: true} // Hidden ancestor. + tree, _ := NewAccessibleTree(testID("/root"), nodes) + ref, _ := tree.Reference(testID("/heading")) + doc, err := tree.Document(context.Background(), ref) + if err != nil || doc.Nodes[1].Hidden || !doc.Nodes[2].Hidden || !doc.Nodes[3].Hidden { + t.Fatalf("visibility: %+v %v", doc, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := tree.Document(ctx, ref); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestDocumentLineAssemblesSameVisualRow(t *testing.T) { + nodes := documentTestNodes() + lineNode := func(index int, path, role, content string, bounds Rectangle, caret bool) { + snapshot := NewSnapshot(path, path, role, "", content) + snapshot.BusName = ":1.42" + nodes[index].Snapshot = snapshot + nodes[index].Geometry = Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + text := VisibleText{Known: true, Ranges: []TextRange{{ + Start: 0, End: utf8.RuneCountInString(content), Text: content, Bounds: bounds, + }}} + if caret { + text.CaretKnown, text.CaretOffset = true, 1 + } + nodes[index].Text = text + } + lineNode(3, "/heading", "heading", "Before", Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, true) + lineNode(5, "/link", "link", "link text", Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, true) + lineNode(6, "/other", "paragraph", "Other row", Rectangle{X: 0, Y: 200, Width: 80, Height: 20}, false) + tree, err := NewAccessibleTree(testID("/root"), nodes) + if err != nil { + t.Fatal(err) + } + ref, err := tree.Reference(testID("/link")) + if err != nil { + t.Fatal(err) + } + line, err := tree.DocumentLineAtOffset(context.Background(), ref, -1) + if err != nil { + t.Fatal(err) + } + var contents []string + for _, content := range line.Contents { + contents = append(contents, content.Text) + } + if !reflect.DeepEqual(contents, []string{"Before", "link text"}) { + t.Fatalf("line contents = %q", contents) + } + if line.Document.ID() != testID("/doc") { + t.Fatalf("line document = %#v", line.Document) + } +} + +func TestDocumentLineExcludesHiddenAndOtherRows(t *testing.T) { + nodes := documentTestNodes() + content := func(index int, text string, bounds Rectangle, caret bool) { + nodes[index].Snapshot.Role = "static" + nodes[index].Geometry = Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + nodes[index].Text = VisibleText{Known: true, Ranges: []TextRange{{ + Start: 0, End: utf8.RuneCountInString(text), Text: text, Bounds: bounds, + }}} + if caret { + nodes[index].Text.CaretKnown, nodes[index].Text.CaretOffset = true, 3 + } + } + content(3, "Visible", Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, true) + content(5, "Hidden", Rectangle{X: 100, Y: 100, Width: 50, Height: 20}, false) + content(6, "Other row", Rectangle{X: 0, Y: 200, Width: 80, Height: 20}, false) + nodes[4].Geometry = Geometry{VisibilityKnown: true, Visible: false} + tree, err := NewAccessibleTree(testID("/root"), nodes) + if err != nil { + t.Fatal(err) + } + ref, err := tree.Reference(testID("/heading")) + if err != nil { + t.Fatal(err) + } + line, err := tree.DocumentLineAtOffset(context.Background(), ref, -1) + if err != nil { + t.Fatal(err) + } + if len(line.Contents) != 1 || line.Contents[0].Text != "Visible" { + t.Fatalf("line contents = %#v", line.Contents) + } + emptyNodes := documentTestNodes() + withoutCaretTree, err := NewAccessibleTree(testID("/root"), emptyNodes) + if err != nil { + t.Fatal(err) + } + withoutCaret, err := withoutCaretTree.Reference(testID("/heading")) + if err != nil { + t.Fatal(err) + } + if _, err := withoutCaretTree.DocumentLineAtOffset(context.Background(), withoutCaret, -1); !errors.Is(err, ErrNoDocumentCaret) { + t.Fatalf("missing caret error = %v", err) + } +} + +func TestDocumentLineAcceptsEndOfTextCaretOffset(t *testing.T) { + nodes := documentTestNodes() + content := "Visible" + nodes[3].Snapshot.Role = "static" + nodes[3].Geometry = Geometry{ + Bounds: Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, BoundsKnown: true, + VisibilityKnown: true, Visible: true, Showing: true, + } + nodes[3].Text = VisibleText{Known: true, Ranges: []TextRange{{ + Start: 0, End: utf8.RuneCountInString(content), Text: content, + Bounds: nodes[3].Geometry.Bounds, + }}} + tree, err := NewAccessibleTree(testID("/root"), nodes) + if err != nil { + t.Fatal(err) + } + reference, err := tree.Reference(testID("/heading")) + if err != nil { + t.Fatal(err) + } + line, err := tree.DocumentLineAtOffset(context.Background(), reference, utf8.RuneCountInString(content)) + if err != nil { + t.Fatal(err) + } + if len(line.Contents) != 1 || line.Contents[0].Text != content { + t.Fatalf("line contents = %#v", line.Contents) + } +} + +func TestDocumentLifecycleRetiresReusedIdentity(t *testing.T) { + for _, kind := range []string{DocumentReloadEvent, DocumentLoadCompleteEvent, DocumentLoadStoppedEvent} { + t.Run(kind, func(t *testing.T) { + nodes := documentTestNodes() + tree, _ := NewAccessibleTree(testID("/root"), nodes) + adapter := New() + adapter.tree = tree + doc, _ := tree.Reference(testID("/doc")) + child, _ := tree.Reference(testID("/link")) + chrome, _ := tree.Reference(testID("/chrome")) + adapter.invalidateTreeEvent(kind, 0, doc.ID(), ObjectID{}) + if err := tree.Replace(testID("/root"), nodes); err != nil { + t.Fatal(err) + } + for _, ref := range []ObjectReference{doc, child} { + if _, err := tree.Resolve(ref); !errors.Is(err, ErrStaleObject) { + t.Fatalf("reused reference survived: %v", err) + } + } + if _, err := tree.Resolve(chrome); err != nil { + t.Fatalf("unrelated chrome: %v", err) + } + if adapter.TreeInvalidations() != 4 { + t.Fatalf("invalidation count: %d", adapter.TreeInvalidations()) + } + }) + } +} + +func TestDocumentCaptureIsAtomicAcrossReplacement(t *testing.T) { + tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes()) + ref, _ := tree.Reference(testID("/link")) + var workers sync.WaitGroup + workers.Add(1) + go func() { + defer workers.Done() + for i := 0; i < 100; i++ { + nodes := documentTestNodes() + for j := range nodes { + nodes[j].Snapshot.Name = "replacement" + } + if err := tree.Replace(testID("/root"), nodes); err != nil { + t.Error(err) + return + } + } + }() + for i := 0; i < 100; i++ { + doc, err := tree.Document(context.Background(), ref) + if err != nil { + t.Fatal(err) + } + replaced := doc.Context.Ancestors[0].Name == "replacement" + for _, node := range doc.Nodes { + if (node.Snapshot.Name == "replacement") != replaced { + t.Fatal("mixed document captures") + } + } + } + workers.Wait() +} + +func TestDocumentIgnoresCaretAndViewportInvalidation(t *testing.T) { + for _, event := range []string{CaretEvent, BoundsChangedEvent, TextBoundsChangedEvent, ShowingStateEvent, TextInsertedEvent, VisibleStateEvent} { + t.Run(event, func(t *testing.T) { + adapter := New() + adapter.documentTree, _ = NewAccessibleTree(testID("/root"), documentTestNodes()) + adapter.tree, _ = NewAccessibleTree(testID("/root"), documentTestNodes()) + ref, _ := adapter.documentTree.Reference(testID("/heading")) + adapter.invalidateTreeEvent(event, 1, ref.ID(), ObjectID{}) + _, err := adapter.documentTree.Document(context.Background(), ref) + mustRefresh := event == TextInsertedEvent || event == VisibleStateEvent + if (err != nil) != mustRefresh { + t.Fatalf("document error = %v, refresh = %v", err, mustRefresh) + } + flatRef, _ := adapter.tree.Reference(testID("/heading")) + if _, err := adapter.tree.Document(context.Background(), flatRef); err == nil { + t.Fatal("flat review failed to invalidate") + } + }) + } +} diff --git a/internal/atspi/document_text_lines.go b/internal/atspi/document_text_lines.go new file mode 100644 index 0000000..2cae038 --- /dev/null +++ b/internal/atspi/document_text_lines.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "strings" + "unicode" + "unicode/utf8" +) + +// Match Python web elementLinesAreSingleWords/Chars: CSS text can expose one +// token per native LINE despite belonging to one logical line. The caller +// excludes editable and preformatted text and caches the bounded decision. +func documentLinesAreTokens(ctx context.Context, source reviewTextSource, content string) (bool, error) { + count := utf8.RuneCountInString(content) + if count < 2 || count > maxReviewLineCharacters || float64(strings.Count(content, "\ufffc"))/float64(count) > .3 { + return false, nil + } + words := strings.FieldsFunc(content, func(value rune) bool { return unicode.IsSpace(value) || value == '\ufffc' }) + singleWords, singleChars := len(words) > 1, true + for offset, attempts := 0, 0; offset < count; attempts++ { + if err := ctx.Err(); err != nil { + return false, err + } + if attempts >= maxReviewRanges { + return false, nil + } + line, err := source.line(ctx, offset) + if err != nil { + return false, err + } + if line.Start < 0 || line.Start > offset || line.End <= offset || line.End > count { + return false, nil + } + value := strings.TrimSpace(line.Text) + singleWords = singleWords && len(strings.Fields(line.Text)) == 1 + singleChars = singleChars && (value == "" || value == "\ufffc" || value == "�" || utf8.RuneCountInString(value) == 1) + if !singleWords && !singleChars { + return false, nil + } + offset = line.End + } + return singleWords || singleChars, ctx.Err() +} diff --git a/internal/atspi/document_text_lines_test.go b/internal/atspi/document_text_lines_test.go new file mode 100644 index 0000000..236fc58 --- /dev/null +++ b/internal/atspi/document_text_lines_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "errors" + "testing" +) + +func TestDocumentLinesAreTokens(t *testing.T) { + for _, test := range []struct { + name, text string + lines []TextRange + want bool + }{ + {"words", "What's on your mind?", []TextRange{{Start: 0, End: 7, Text: "What's "}, {Start: 7, End: 10, Text: "on "}, {Start: 10, End: 15, Text: "your "}, {Start: 15, End: 20, Text: "mind?"}}, true}, + {"unicode characters", "é 文", []TextRange{{Start: 0, End: 1, Text: "é"}, {Start: 1, End: 2, Text: " "}, {Start: 2, End: 3, Text: "文"}}, true}, + {"ordinary lines", "First line\nSecond line", []TextRange{{Start: 0, End: 11, Text: "First line\n"}, {Start: 11, End: 22, Text: "Second line"}}, false}, + {"one word", "autocomplete", []TextRange{{Start: 0, End: 12, Text: "autocomplete"}}, false}, + {"embedded objects", "\ufffc\ufffc\ufffc hello", nil, false}, + {"non advancing provider", "one two", []TextRange{{Start: 0, End: 0, Text: "one"}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + source := fakeReviewText{lines: make(map[int]TextRange)} + for _, line := range test.lines { + source.lines[line.Start] = line + } + got, err := documentLinesAreTokens(context.Background(), source, test.text) + if err != nil || got != test.want { + t.Fatalf("repair = %v, %v; want %v", got, err, test.want) + } + }) + } +} + +func TestDocumentLinesProbeHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + source := fakeReviewText{cancel: cancel, lines: map[int]TextRange{0: {Start: 0, End: 2, Text: "a "}, 2: {Start: 2, End: 3, Text: "b"}}} + _, err := documentLinesAreTokens(ctx, source, "a b") + if !errors.Is(err, context.Canceled) { + t.Fatalf("probe error = %v", err) + } +} diff --git a/internal/atspi/firefox_native_test.go b/internal/atspi/firefox_native_test.go new file mode 100644 index 0000000..d20992a --- /dev/null +++ b/internal/atspi/firefox_native_test.go @@ -0,0 +1,176 @@ +// 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" + "strings" + "testing" + "time" +) + +func TestNativeFirefoxBaseline(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_FIREFOX") != "1" { + t.Skip("requires private Firefox session") + } + ctx, cancel := context.WithTimeout(context.Background(), 35*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) + } + }() + + window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Firefox Baseline").Output() + if err != nil || len(strings.Fields(string(window))) == 0 { + t.Fatalf("Firefox window: %s: %v", window, err) + } + if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Fields(string(window))[0]).CombinedOutput(); err != nil { + t.Fatalf("focus Firefox: %s: %v", out, err) + } + var tree *AccessibleTree + for ctx.Err() == nil { + tree, err = adapter.CaptureDesktop(ctx) + if err == nil && findNamedReference(t, tree, "Firefox baseline heading").Valid() { + break + } + time.Sleep(200 * time.Millisecond) + } + if err != nil { + t.Fatalf("capture Firefox desktop: %v", err) + } + heading := findNamedReference(t, tree, "Firefox baseline heading") + if !heading.Valid() { + t.Fatal("Firefox page heading absent from AT-SPI tree") + } + snapshot, err := tree.Resolve(heading) + if err != nil { + t.Fatal(err) + } + if snapshot.HeadingLevel != 1 { + t.Fatalf("Firefox heading semantics: role=%q level=%d", snapshot.Role, snapshot.HeadingLevel) + } + owner, err := tree.DocumentContext(heading) + if err != nil { + t.Fatalf("Firefox heading has no web document: %v", err) + } + document, err := tree.Document(ctx, heading) + if err != nil { + t.Fatalf("capture Firefox document: %v", err) + } + paragraphFound := false + for _, node := range document.Nodes { + paragraphFound = paragraphFound || node.Snapshot.Role == "paragraph" + } + if !paragraphFound { + t.Fatal("Firefox paragraph absent from document") + } + for _, name := range []string{"Firefox baseline link", "Firefox baseline entry"} { + if !findNamedReference(t, tree, name).Valid() { + t.Errorf("Firefox page object %q absent", name) + } + } + documentRoot, err := tree.Resolve(owner.Document) + if err != nil || documentRoot.Role != "document web" { + t.Fatalf("Firefox document root=%+v err=%v", documentRoot, err) + } + t.Logf("Firefox document role=%q nodes=%d heading=%q", documentRoot.Role, len(document.Nodes), snapshot.Name) + if out, err := exec.CommandContext(ctx, "xdotool", "key", "ctrl+l").CombinedOutput(); err != nil { + t.Fatalf("focus address bar: %s: %v", out, err) + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() +chromeFocus: + for { + select { + case event := <-adapter.Events(): + if event.Source.ApplicationProcess != "firefox" || !event.Source.Focused || event.Source.Role != "combo box" { + continue + } + if event.Type != FocusEvent && event.Type != FocusedStateEvent { + continue + } + if _, err := adapter.CaptureDocumentContext(ctx, event.Source.ID()); err != ErrNotInDocument { + t.Fatalf("Firefox chrome %q belongs to page document: %v", event.Source.Name, err) + } + if !event.Source.Autocomplete || !event.Source.HasPopup || !event.Source.Editable { + t.Fatalf("Firefox address bar semantics: role=%q editable=%t popup=%t autocomplete=%t", event.Source.Role, event.Source.Editable, event.Source.HasPopup, event.Source.Autocomplete) + } + t.Logf("Firefox chrome focus role=%q name=%q process=%q editable=%t popup=%t autocomplete=%t", event.Source.Role, event.Source.Name, event.Source.ApplicationProcess, event.Source.Editable, event.Source.HasPopup, event.Source.Autocomplete) + break chromeFocus + case <-deadline.C: + t.Fatal("Firefox address-bar focus event missing") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + // Return through Firefox's real keyboard order; a tree-only capture cannot + // establish whether the reader can recapture the active page after chrome. + for attempt := 0; attempt < 25; attempt++ { + if out, err := exec.CommandContext(ctx, "xdotool", "key", "Tab").CombinedOutput(); err != nil { + t.Fatalf("tab from Firefox chrome: %s: %v", out, err) + } + wait := time.NewTimer(300 * time.Millisecond) + events: + for { + select { + case event := <-adapter.Events(): + if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.ApplicationProcess != "firefox" || !event.Source.Focused { + continue + } + if event.Source.Name != "Firefox baseline link" && event.Source.Name != "Firefox baseline entry" { + continue + } + wait.Stop() + owner, err := adapter.CaptureDocumentContext(ctx, event.Source.ID()) + if err != nil || !owner.Document.Valid() { + t.Fatalf("Firefox page focus context: %v", err) + } + page, err := adapter.CaptureDocument(ctx, event.Source.ID()) + if err != nil { + t.Fatalf("Firefox focused document capture: %v", err) + } + ref := findNamedReference(t, page, "Firefox baseline heading") + if !ref.Valid() { + t.Fatal("Firefox heading missing from focused document capture") + } + var paragraph ObjectReference + captured, err := page.Document(ctx, ref) + if err != nil { + t.Fatal(err) + } + for _, node := range captured.Nodes { + if node.Snapshot.Role == "paragraph" { + paragraph = node.Reference + break + } + } + if !paragraph.Valid() { + t.Fatal("Firefox paragraph missing from focused document capture") + } + line, err := adapter.ReadDocumentCaret(ctx, paragraph, 0, DocumentCaretBrailleLine) + if err != nil || strings.TrimSpace(line.Text) != "Firefox baseline paragraph." { + t.Fatalf("Firefox paragraph line=%q err=%v", line.Text, err) + } + t.Logf("Firefox page focus role=%q name=%q", event.Source.Role, event.Source.Name) + return + case <-wait.C: + break events + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + wait.Stop() + } + t.Fatal("Firefox page focus unreachable from address bar with Tab") +} diff --git a/internal/atspi/foreground.go b/internal/atspi/foreground.go new file mode 100644 index 0000000..08a2c2f --- /dev/null +++ b/internal/atspi/foreground.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "math" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +// Epoch changes with the foreground client, including departure to an empty +// workspace. Moving/resizing the same client does not reset reading ownership. +type ForegroundState struct { + Client xorg.Foreground + Epoch uint64 +} + +func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle) bool { + if client.PID > 0 && pid > 0 && client.PID != pid { + return false + } + if !bounds.Valid() || client.Width <= 0 || client.Height <= 0 { + return false + } + inner := Rectangle{X: int32(client.X), Y: int32(client.Y), Width: int32(client.Width), Height: int32(client.Height)} + outer := Rectangle{X: int32(client.OuterX), Y: int32(client.OuterY), Width: int32(client.OuterWidth), Height: int32(client.OuterHeight)} + return bounds == inner || bounds == outer || + scaledWindowMatches(bounds, inner) || scaledWindowMatches(bounds, outer) +} + +// Some Chromium windows report AT-SPI extents in logical pixels while X11 +// reports physical pixels. Require the same origin and proportional dimensions +// so a smaller dialog or unrelated sibling cannot claim the foreground client. +func scaledWindowMatches(accessible, xWindow Rectangle) bool { + if !accessible.Valid() || !xWindow.Valid() || accessible.X != xWindow.X || accessible.Y != xWindow.Y { + return false + } + scaleX := float64(xWindow.Width) / float64(accessible.Width) + scaleY := float64(xWindow.Height) / float64(accessible.Height) + if scaleX < 0.5 || scaleX > 3 || scaleY < 0.5 || scaleY > 3 { + return false + } + return math.Abs(scaleX-scaleY) <= 0.01*math.Max(scaleX, scaleY) +} + +// A menu popup remains within the foreground application's AT-SPI bus even +// when its own accessible frame does not match the X11 foreground client's +// bounds. The caller must confirm an active menu window or a focused menu item. +func foregroundMenuPopupMatches(client xorg.Foreground, foregroundWindow, popupWindow ObjectID, pid int, menuContext bool) bool { + return menuContext && client.Window != 0 && foregroundWindow.Valid() && popupWindow.Valid() && + foregroundWindow != popupWindow && foregroundWindow.BusName == popupWindow.BusName && + (client.PID <= 0 || pid <= 0 || client.PID == pid) +} diff --git a/internal/atspi/foreground_test.go b/internal/atspi/foreground_test.go new file mode 100644 index 0000000..b673114 --- /dev/null +++ b/internal/atspi/foreground_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package atspi + +import ( + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "testing" +) + +func TestForegroundWindowCorrelation(t *testing.T) { + client := xorg.Foreground{Known: true, Window: 45, PID: 123, X: 10, Y: 30, Width: 400, Height: 300, OuterX: 8, OuterY: 5, OuterWidth: 404, OuterHeight: 327} + for _, test := range []struct { + name string + pid int + bounds Rectangle + want bool + }{ + {"client", 123, Rectangle{10, 30, 400, 300}, true}, + {"decorated", 123, Rectangle{8, 5, 404, 327}, true}, + {"other process", 124, Rectangle{10, 30, 400, 300}, false}, + {"same process other window", 123, Rectangle{420, 30, 400, 300}, false}, + {"same process scaled window", 123, Rectangle{10, 30, 200, 150}, true}, + {"scaled window shifted", 123, Rectangle{11, 30, 200, 150}, false}, + {"scaled window wrong aspect", 123, Rectangle{10, 30, 200, 120}, false}, + {"scaled window too small", 123, Rectangle{10, 30, 100, 75}, false}, + {"other process scaled window", 124, Rectangle{10, 30, 200, 150}, false}, + {"dialog", 123, Rectangle{40, 50, 200, 100}, false}, + {"missing bounds", 123, Rectangle{}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := foregroundWindowMatches(client, test.pid, test.bounds); got != test.want { + t.Fatalf("match = %v, want %v", got, test.want) + } + }) + } +} + +func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) { + client := xorg.Foreground{Known: true, Window: 83886130, PID: 111, X: 0, Y: 0, Width: 1920, Height: 1080, + OuterX: 0, OuterY: 0, OuterWidth: 1920, OuterHeight: 1080} + if !foregroundWindowMatches(client, 111, Rectangle{X: 0, Y: 0, Width: 1353, Height: 761}) { + t.Fatal("same-process logical-pixel frame did not match X11 foreground") + } +} + +func TestForegroundMenuPopupCorrelation(t *testing.T) { + client := xorg.Foreground{Known: true, Window: 45, PID: 123} + main := NewObjectID(":1.40", "/main") + popup := NewObjectID(":1.40", "/popup") + for _, test := range []struct { + name string + client xorg.Foreground + main ObjectID + popup ObjectID + pid int + menu bool + want bool + }{ + {"active menu in foreground application", client, main, popup, 123, true, true}, + {"inactive menu", client, main, popup, 123, false, false}, + {"other application", client, main, NewObjectID(":1.41", "/popup"), 123, true, false}, + {"other process", client, main, popup, 124, true, false}, + {"ordinary sibling window", client, main, popup, 123, false, false}, + {"no foreground client", xorg.Foreground{Known: true}, main, popup, 123, true, false}, + {"no owned main window", client, ObjectID{}, popup, 123, true, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := foregroundMenuPopupMatches(test.client, test.main, test.popup, test.pid, test.menu); got != test.want { + t.Fatalf("match = %v, want %v", got, test.want) + } + }) + } +} diff --git a/internal/atspi/geometry.go b/internal/atspi/geometry.go new file mode 100644 index 0000000..a129b10 --- /dev/null +++ b/internal/atspi/geometry.go @@ -0,0 +1,69 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import "math" + +// Rectangle uses AT-SPI screen coordinates. Negative positions are valid on +// multi-monitor desktops. Edges use int64 arithmetic to avoid native gint +// overflow. Zero-sized rectangles cannot contribute a flat-review zone. +type Rectangle struct { + X, Y, Width, Height int32 +} + +func (rect Rectangle) Valid() bool { + return rect.Width > 0 && rect.Height > 0 && rect.X != math.MinInt32 && rect.Y != math.MinInt32 +} + +// Intersection clips a screen rectangle without wrapping at native integer +// limits. A disjoint, empty, or unavailable rectangle has no intersection. +func (rect Rectangle) Intersection(other Rectangle) (Rectangle, bool) { + if !rect.Valid() || !other.Valid() { + return Rectangle{}, false + } + left := max(int64(rect.X), int64(other.X)) + top := max(int64(rect.Y), int64(other.Y)) + right := min(int64(rect.X)+int64(rect.Width), int64(other.X)+int64(other.Width)) + bottom := min(int64(rect.Y)+int64(rect.Height), int64(other.Y)+int64(other.Height)) + if right <= left || bottom <= top { + return Rectangle{}, false + } + return Rectangle{int32(left), int32(top), int32(right - left), int32(bottom - top)}, true +} + +// Geometry is owned capture-time component data, separate from focus snapshots. +// Bounds are meaningful only when visibility is known and the object is both +// visible and showing. Neither state establishes that another window does not +// obscure the object. Unsupported components retain unknown bounds. +type Geometry struct { + Bounds Rectangle + BoundsKnown bool + VisibilityKnown bool + Visible bool + Showing bool +} + +func (geometry Geometry) valid() bool { + if !geometry.VisibilityKnown && (geometry.Visible || geometry.Showing) { + return false + } + if geometry.BoundsKnown { + return geometry.VisibilityKnown && geometry.Visible && geometry.Showing && geometry.Bounds.Valid() + } + return geometry.Bounds == (Rectangle{}) +} diff --git a/internal/atspi/geometry_test.go b/internal/atspi/geometry_test.go new file mode 100644 index 0000000..9b9cf77 --- /dev/null +++ b/internal/atspi/geometry_test.go @@ -0,0 +1,136 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "errors" + "math" + "sync" + "testing" +) + +func TestRectangleIntersection(t *testing.T) { + tests := []struct { + name string + first, second, want Rectangle + ok bool + }{ + {"overlap", Rectangle{10, 20, 30, 40}, Rectangle{20, 30, 50, 60}, Rectangle{20, 30, 20, 30}, true}, + {"negative monitor", Rectangle{-100, -50, 90, 40}, Rectangle{-80, -30, 50, 50}, Rectangle{-80, -30, 50, 20}, true}, + {"touching edge", Rectangle{0, 0, 10, 10}, Rectangle{10, 0, 5, 5}, Rectangle{}, false}, + {"disjoint", Rectangle{0, 0, 10, 10}, Rectangle{20, 0, 5, 5}, Rectangle{}, false}, + {"empty", Rectangle{}, Rectangle{0, 0, 10, 10}, Rectangle{}, false}, + {"negative size", Rectangle{0, 0, -1, 10}, Rectangle{0, 0, 10, 10}, Rectangle{}, false}, + {"unavailable position", Rectangle{math.MinInt32, 0, 10, 10}, Rectangle{math.MinInt32, 0, 10, 10}, Rectangle{}, false}, + {"edge overflow", Rectangle{math.MaxInt32 - 5, 0, 10, 10}, Rectangle{math.MaxInt32 - 2, 1, 10, 10}, Rectangle{math.MaxInt32 - 2, 1, 7, 9}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := tt.first.Intersection(tt.second) + if ok != tt.ok || got != tt.want { + t.Fatalf("intersection = %v %v, want %v %v", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestTreeGeometryInvalidationPreservesHierarchy(t *testing.T) { + nodes := []TreeNode{ + testNode("/root", "Window", testID("/first"), testID("/second")), + testNode("/first", "First"), + testNode("/second", "Second", testID("/child")), + testNode("/child", "Child"), + } + geometry := Geometry{Bounds: Rectangle{-10, 20, 30, 40}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + for i := range nodes { + nodes[i].Geometry = geometry + } + tree, err := NewAccessibleTree(testID("/root"), nodes) + if err != nil { + t.Fatal(err) + } + second, _ := tree.Reference(testID("/second")) + child, _, _ := tree.FirstChild(second) + first, _ := tree.Reference(testID("/first")) + if count := tree.InvalidateGeometry(second.ID()); count != 2 { + t.Fatalf("invalidated %d", count) + } + if count := tree.InvalidateGeometry(second.ID()); count != 0 { + t.Fatalf("repeat invalidated %d", count) + } + for _, ref := range []ObjectReference{second, child} { + if _, err := tree.Geometry(ref); !errors.Is(err, ErrStaleGeometry) { + t.Fatalf("geometry = %v", err) + } + if _, err := tree.Resolve(ref); err != nil { + t.Fatalf("geometry invalidation destroyed identity: %v", err) + } + } + if got, err := tree.Geometry(first); err != nil || got != geometry { + t.Fatalf("unrelated geometry = %v %v", got, err) + } + if got, ok, err := tree.FirstChild(second); err != nil || !ok || got != child { + t.Fatalf("child = %v %v %v", got, ok, err) + } + nodes[2].Geometry.Bounds.X = 25 + if err := tree.Replace(testID("/root"), nodes); err != nil { + t.Fatal(err) + } + if got, err := tree.Geometry(second); err != nil || got.Bounds.X != 25 { + t.Fatalf("recapture = %v %v", got, err) + } + tree.Invalidate(second.ID()) + if _, err := tree.Geometry(child); !errors.Is(err, ErrStaleObject) { + t.Fatalf("removed geometry = %v", err) + } +} + +func TestTreeRejectsMalformedGeometry(t *testing.T) { + for _, geometry := range []Geometry{ + {Showing: true}, + {Bounds: Rectangle{0, 0, 10, 10}}, + {BoundsKnown: true}, + {Bounds: Rectangle{0, 0, 10, 10}, BoundsKnown: true, VisibilityKnown: true, Visible: true}, + } { + node := testNode("/root", "Window") + node.Geometry = geometry + if _, err := NewAccessibleTree(testID("/root"), []TreeNode{node}); !errors.Is(err, ErrInvalidTree) { + t.Fatalf("accepted malformed geometry %v: %v", geometry, err) + } + } +} + +func TestTreeGeometryConcurrentInvalidation(t *testing.T) { + tree := testTree(t) + ref, _ := tree.Reference(testID("/second")) + var readers sync.WaitGroup + for range 8 { + readers.Add(1) + go func() { + defer readers.Done() + for range 100 { + if _, err := tree.Geometry(ref); err != nil && !errors.Is(err, ErrStaleGeometry) { + t.Errorf("geometry: %v", err) + return + } + } + }() + } + tree.InvalidateGeometry(ref.ID()) + readers.Wait() +} diff --git a/internal/atspi/model.go b/internal/atspi/model.go new file mode 100644 index 0000000..c5c619a --- /dev/null +++ b/internal/atspi/model.go @@ -0,0 +1,423 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "strings" + "time" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/webscripts" +) + +const maxSnapshotFieldLength = 4096 + +type Snapshot struct { + TextAttributeRun TextAttributeRun + TextAttributeRuns []TextAttributeRun + Mnemonic string + Misspelled bool + SpeechContext []Snapshot // Bounded copied ancestor metadata, without native handles. + BrailleContext []Snapshot // Nearest ancestor first, including window/application. + Spreadsheet bool + // BrowserTab identifies browser chrome, excluding page-authored tab widgets. + BrowserTab bool + // ExplicitName distinguishes an author-supplied link label from a name + // derived from descendants. Only the former makes a link one browse stop. + ExplicitName bool + // Autocomplete describes a single-line completion entry. Popup ownership + // comes from the native POPUP_FOR relation, not a control's name. + Autocomplete, AutocompletePopup bool + SupportsClick bool + LiveRegion bool + LivePoliteness string + LiveAtomic bool + LayoutTable bool + // ChildCount is copied only for list containers. A failed provider query + // leaves ChildCountKnown false so zero is not mistaken for an empty list. + ChildCount int + ChildCountKnown bool + + TableRowCount int + TableColumnCount int + TableName string + TablePath string + TableRowIndex int + TableColumnIndex int + TablePositionKnown bool + TableRowSpan, TableColumnSpan int + TableRowHeaders, TableColumnHeaders string + TableRowText string + GUITable bool + CharacterCount int + EmbeddedObjectCount int + // Selection offsets are captured for text-bearing focused events. + SelectionStart, SelectionEnd int + SelectionText string + CompletionPrefixCharacter string + // CaretWord is captured only for caret movement, not tree traversal. + CaretWord string + // StatesKnown reports whether native capture returned a state set. + // A missing set must not be interpreted as known non-focusable content. + Visible, Showing bool + StatesKnown bool + Active bool + Focusable bool + Focused bool + HasPopup bool + HeadingLevel int + Checked bool + Defunct bool + Editable bool + Expandable bool + Expanded bool + Indeterminate bool + Invalid bool + Multiselectable bool + ReadOnly bool + Required bool + Selected bool + Sensitive bool + SensitiveKnown bool + ValueKnown bool + Visited bool + CurrentValue float64 + MaximumValue float64 + MinimumValue float64 + MinimumIncrement float64 + BusName string + Path string + Name string + Role string + RadioGroupName string + Description string + Text string + // BrailleLine preserves the captured line's spacing; Text is normalized + // for speech and cannot locate a cursor after repeated spaces. + BrailleLine string + // Absolute Unicode offsets, before speech normalization. + BrailleLineStart int + BrailleCaret int + CaretCharacter string + TextBeforeCaret string + TextCaretKnown bool + ValueText string + ApplicationName string + // ApplicationProcess is resolved from the provider PID, not page content. + ApplicationProcess string + // ToolkitName is captured for name-change events to select web policy + // even before document focus or an address-bar event has arrived. + ToolkitName string + XMLRoles string + HTMLTag string + ElementID string + + // PositionInSet is one-based; zero means unavailable. SetSize must be + // positive and at least PositionInSet before presentation uses it. + PositionInSet, SetSize int + + // SoundGeometry contains immutable screen geometry copied on the AT-SPI + // event-loop thread. Presentation and audio workers never retain or query + // the native accessibles from which it was derived. + SoundGeometry SoundGeometry +} + +// TextAttributeRun is the native attribute range at the caret, copied without +// retaining an AT-SPI object. Offsets are Unicode character offsets. +type TextAttributeRun struct { + Start, End int + Values map[string]string +} + +func captureBrailleLine(snapshot *Snapshot, text, beforeCaret string, start int) { + snapshot.BrailleLine = sanitizeTextContext(text) + // sanitizeTextContext retains a bounded suffix. Retain its source origin, + // and hide a caret which is outside that suffix rather than misrouting it. + snapshot.BrailleLineStart = start + utf8.RuneCountInString(text) - utf8.RuneCountInString(snapshot.BrailleLine) + snapshot.BrailleCaret = start + utf8.RuneCountInString(beforeCaret) +} + +type SoundGeometry struct { + Object, Reference Rectangle + ObjectKnown bool + ReferenceKnown bool +} + +// ObjectID identifies one accessible for the lifetime of its AT-SPI bus +// connection. Object paths alone are not unique because different +// applications can publish the same path. +type ObjectID struct { + BusName string + Path string +} + +func NewObjectID(busName, path string) ObjectID { + return ObjectID{ + BusName: sanitizeSnapshotField(busName), + Path: sanitizeSnapshotField(path), + } +} + +func (id ObjectID) Valid() bool { + return id.BusName != "" && id.Path != "" && + validBusName(id.BusName) && validObjectPath(id.Path) && + id == NewObjectID(id.BusName, id.Path) +} + +func validBusName(name string) bool { + if len(name) > 255 { + return false + } + unique := strings.HasPrefix(name, ":") + if unique { + name = strings.TrimPrefix(name, ":") + } + parts := strings.Split(name, ".") + if len(parts) < 2 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + for index, character := range []byte(part) { + letter := character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' + digit := character >= '0' && character <= '9' + if !letter && !digit && character != '_' && character != '-' { + return false + } + if index == 0 && !unique && digit { + return false + } + } + } + return true +} + +func validObjectPath(path string) bool { + if path == "/" { + return true + } + if !strings.HasPrefix(path, "/") || strings.HasSuffix(path, "/") { + return false + } + for _, part := range strings.Split(path[1:], "/") { + if part == "" { + return false + } + for _, character := range []byte(part) { + letter := character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' + digit := character >= '0' && character <= '9' + if !letter && !digit && character != '_' { + return false + } + } + } + return true +} + +func (snapshot Snapshot) ID() ObjectID { + return ObjectID{BusName: snapshot.BusName, Path: snapshot.Path} +} + +type Event struct { + // WebNotification is selected on the native owner before background-event + // filtering. It contains copied content and no retained native references. + WebNotification *webscripts.Notification + Foreground ForegroundState + ForegroundMismatch bool + // ForegroundCheck contains numeric, text-free diagnostics for rejected + // window activations. Other events leave it empty. + ForegroundCheck ForegroundCheck + // PopupFor is the autocomplete entry named by the popup's AT-SPI relation. + PopupFor ObjectID + + // FocusOutsideDocument is a freshly verified chrome focus at load + // completion. A page loading behind it must not acquire browse keys. + FocusOutsideDocument ObjectID + // DocumentFragment is the URL fragment of a completed web document load. + DocumentFragment string + // Window identifies the nearest dialog/window/frame which owns Source. + // It is captured from the original event source before activation may be + // redirected to a focused descendant. + Window ObjectID + // WindowRecovered reports an owned frame restored after a browser tab + // temporarily detached its document's AT-SPI parent chain. + WindowRecovered bool + // WindowTitle is the top-level AT-SPI name captured on activation, even + // when Source is the focused descendant. + WindowTitle string + // WindowDescription is the top-level accessible description on activation. + WindowDescription string + // WindowMessage is a simple frame prompt recovered when its first button + // takes focus after activation arrived before the label was available. + WindowMessage string + NavigationEcho bool + SnapshotDuration time.Duration + Type string + // NativeType retains an AT-SPI text-change origin suffix for debug logs. + // Presentation policies use the canonical Type. + NativeType string + Source Snapshot + OriginPath string + Payload string + Detail1 int +} + +type ForegroundCheck struct { + Reason string + AccessiblePID int + Active bool + Showing bool + Iconified bool + Bounds Rectangle +} + +func NewSnapshot(path, name, role, description, text string) Snapshot { + return NewSnapshotWithCaretCharacter(path, name, role, description, text, "") +} + +func NewSnapshotWithCaretCharacter(path, name, role, description, text, caretCharacter string) Snapshot { + return Snapshot{ + Path: sanitizeSnapshotField(path), + Name: sanitizeSnapshotField(name), + Role: sanitizeSnapshotField(role), + Description: sanitizeSnapshotField(description), + Text: sanitizeSnapshotField(text), + CaretCharacter: sanitizeCaretCharacter(caretCharacter), + } +} + +func NewEvent(eventType string, source Snapshot) Event { + eventType = sanitizeSnapshotField(eventType) + event := Event{Type: canonicalTextChangeEventType(eventType), Source: source, OriginPath: source.Path} + if event.Type != eventType { + event.NativeType = eventType + } + return event +} + +// AT-SPI appends an origin to some text mutations (for example, :system). +// The reader handles those mutations by kind; leaving the suffix on Type +// makes them fall through as ordinary object presentation events. +func canonicalTextChangeEventType(eventType string) string { + for _, kind := range []string{TextInsertedEvent, TextDeletedEvent} { + if eventType == kind || strings.HasPrefix(eventType, kind+":") { + return kind + } + } + return eventType +} + +func isBrowserChromeTab(snapshot Snapshot, inDocument bool) bool { + if snapshot.Role != "page tab" || inDocument { + return false + } + switch strings.ToLower(snapshot.ToolkitName) { + case "chromium", "gecko", "webkit", "webkitgtk": + return true + } + return false +} + +// Firefox exposes its address bar as an editable popup combo box without the +// supports-autocompletion state used by Chromium. Keep the fallback in chrome: +// page controls with the same role still use their own document behavior. +func isFirefoxChromeAutocomplete(snapshot Snapshot, inDocument bool) bool { + return !inDocument && strings.EqualFold(snapshot.ApplicationProcess, "firefox") && + snapshot.Role == "combo box" && snapshot.Editable && !snapshot.ReadOnly && snapshot.HasPopup +} + +func NewManagedDescendantEvent(eventType, originPath string, source Snapshot) Event { + event := NewEvent(eventType, source) + event.OriginPath = sanitizeSnapshotField(originPath) + return event +} + +func NewEventWithPayload(eventType string, source Snapshot, payload string) Event { + event := NewEvent(eventType, source) + event.Payload = sanitizeTextPayload(payload) + return event +} + +func sanitizeTextPayload(value string) string { + // Preserve single-character delimiters so typing-echo policy can + // distinguish space, tab, and line boundaries. Other snapshot fields + // continue to trim surrounding whitespace. + switch value { + case " ", "\t", "\n": + return value + case "\r", "\r\n": + return "\n" + } + return sanitizeSnapshotField(value) +} + +func sanitizeTextContext(value string) string { + value = strings.ToValidUTF8(value, "�") + value = strings.Map(func(character rune) rune { + if character == '\r' { + return '\n' + } + if character != '\n' && character != '\t' && unicode.IsControl(character) { + return '�' + } + return character + }, value) + if len(value) > maxSnapshotFieldLength { + value = value[len(value)-maxSnapshotFieldLength:] + for !utf8.ValidString(value) { + value = value[1:] + } + } + return value +} + +func sanitizeSnapshotField(value string) string { + value = strings.ToValidUTF8(value, "�") + value = strings.Map(func(character rune) rune { + if character == '\n' || character == '\r' || character == '\t' { + return ' ' + } + return character + }, value) + value = strings.Join(strings.Fields(value), " ") + value = strings.TrimSpace(value) + if len(value) > maxSnapshotFieldLength { + value = value[:maxSnapshotFieldLength] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + } + return value +} + +func sanitizeCaretCharacter(value string) string { + value = strings.ToValidUTF8(value, "�") + characters := []rune(value) + if len(characters) == 0 { + return "" + } + switch characters[0] { + case '\n', '\r': + return "blank" + default: + return string(characters[0]) + } +} diff --git a/internal/atspi/model_test.go b/internal/atspi/model_test.go new file mode 100644 index 0000000..2b82daf --- /dev/null +++ b/internal/atspi/model_test.go @@ -0,0 +1,233 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "strings" + "testing" +) + +func TestShouldSpeakEvent(t *testing.T) { + tests := []struct { + name string + eventType string + detail1 int + want bool + }{ + {name: "focus event", eventType: FocusEvent, detail1: 0, want: true}, + {name: "focused state", eventType: FocusedStateEvent, detail1: 1, want: true}, + {name: "unfocused state", eventType: FocusedStateEvent, detail1: 0, want: false}, + {name: "window activation", eventType: WindowActivatedEvent, detail1: 0, want: true}, + {name: "value changed", eventType: ValueChangedEvent, detail1: 0, want: true}, + {name: "other state", eventType: "object:state-changed:checked", detail1: 1, want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldSpeakEvent(test.eventType, test.detail1); got != test.want { + t.Fatalf("shouldSpeakEvent(%q, %d) = %v, want %v", test.eventType, test.detail1, got, test.want) + } + }) + } +} + +func TestTextChangeOriginIsCanonicalized(t *testing.T) { + for _, test := range []struct{ incoming, want string }{ + {TextInsertedEvent + ":system", TextInsertedEvent}, + {TextDeletedEvent + ":system", TextDeletedEvent}, + {TextInsertedEvent, TextInsertedEvent}, + {TextDeletedEvent, TextDeletedEvent}, + {"object:text-caret-moved", "object:text-caret-moved"}, + {"object:text-changed:insertion", "object:text-changed:insertion"}, + } { + if got := canonicalTextChangeEventType(test.incoming); got != test.want { + t.Errorf("canonicalTextChangeEventType(%q) = %q, want %q", test.incoming, got, test.want) + } + if got := NewEvent(test.incoming, Snapshot{}).Type; got != test.want { + t.Errorf("NewEvent(%q).Type = %q, want %q", test.incoming, got, test.want) + } + if test.incoming != test.want && NewEvent(test.incoming, Snapshot{}).NativeType != test.incoming { + t.Errorf("NewEvent(%q) lost its native type", test.incoming) + } + } +} + +func TestFirefoxChromeAutocompleteBoundary(t *testing.T) { + address := Snapshot{ApplicationProcess: "firefox", Role: "combo box", Editable: true, HasPopup: true} + if !isFirefoxChromeAutocomplete(address, false) { + t.Fatal("Firefox chrome address bar was not recognized") + } + for _, test := range []struct { + name string + snapshot Snapshot + inDocument bool + }{ + {"page form", address, true}, + {"Thunderbird", Snapshot{ApplicationProcess: "thunderbird", Role: "combo box", Editable: true, HasPopup: true}, false}, + {"Chromium", Snapshot{ApplicationProcess: "chromium", Role: "combo box", Editable: true, HasPopup: true}, false}, + {"read only", Snapshot{ApplicationProcess: "firefox", Role: "combo box", ReadOnly: true, Editable: true, HasPopup: true}, false}, + {"no popup", Snapshot{ApplicationProcess: "firefox", Role: "combo box", Editable: true}, false}, + } { + if isFirefoxChromeAutocomplete(test.snapshot, test.inDocument) { + t.Errorf("%s was classified as Firefox chrome autocomplete", test.name) + } + } +} + +func TestAdapterRegistersWindowLifecycle(t *testing.T) { + for _, wanted := range []string{WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent} { + found := false + for _, eventType := range eventTypes { + if eventType == wanted { + found = true + break + } + } + if !found { + t.Fatalf("window lifecycle event %q is not registered", wanted) + } + } +} + +func TestNewSnapshotSanitizesFields(t *testing.T) { + snapshot := NewSnapshot("/org/a11y/atspi/accessible/1", " Generic\r\nname ", "text frame", "Invalid: \xff", "Current\r\nline ") + if snapshot.Path != "/org/a11y/atspi/accessible/1" { + t.Fatalf("path = %q", snapshot.Path) + } + if snapshot.Name != "Generic name" { + t.Fatalf("name = %q, want %q", snapshot.Name, "Generic name") + } + if snapshot.Description != "Invalid: �" { + t.Fatalf("description = %q, want %q", snapshot.Description, "Invalid: �") + } +} + +func TestObjectIDIncludesBusNameAndPath(t *testing.T) { + snapshot := NewSnapshot("/org/a11y/atspi/accessible/1", "Example", "push button", "", "") + snapshot.BusName = ":1.42" + if got := snapshot.ID(); got != (ObjectID{BusName: ":1.42", Path: "/org/a11y/atspi/accessible/1"}) { + t.Fatalf("object ID = %#v", got) + } + if NewObjectID(" :1.42\n", " /org/a11y/atspi/accessible/1 ") != snapshot.ID() { + t.Fatal("new object ID was not sanitized") + } + if !(ObjectID{BusName: "org.a11y.atspi.Registry", Path: "/org/a11y/atspi/accessible/root"}).Valid() { + t.Fatal("well-known registry identity is invalid") + } + for _, invalid := range []ObjectID{ + {BusName: "missing-dot", Path: "/object"}, + {BusName: "7org.example", Path: "/object"}, + {BusName: "org.example", Path: "relative"}, + {BusName: "org.example", Path: "/invalid path"}, + {BusName: "org.example", Path: "/double//separator"}, + } { + if invalid.Valid() { + t.Fatalf("invalid identity accepted: %#v", invalid) + } + } +} + +func TestNewSnapshotLimitsFieldLength(t *testing.T) { + for _, character := range []string{"é", "€", "😀"} { + t.Run(character, func(t *testing.T) { + snapshot := NewSnapshot("", strings.Repeat(character, 5000), "", "", "") + if len(snapshot.Name) > maxSnapshotFieldLength { + t.Fatalf("name length = %d, want <= %d", len(snapshot.Name), maxSnapshotFieldLength) + } + if !strings.HasSuffix(snapshot.Name, character) { + t.Fatal("field truncation split a UTF-8 rune") + } + }) + } +} + +func TestNewSnapshotWithCaretCharacter(t *testing.T) { + snapshot := NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt ls", "l", + ) + if snapshot.CaretCharacter != "l" { + t.Fatalf("caret character = %q, want %q", snapshot.CaretCharacter, "l") + } +} + +func TestNewSnapshotPreservesCaretSpace(t *testing.T) { + snapshot := NewSnapshotWithCaretCharacter("", "", "", "", "", " space ") + if snapshot.CaretCharacter != " " { + t.Fatalf("caret character = %q, want %q", snapshot.CaretCharacter, " ") + } +} + +func TestNewEventWithPayloadSanitizesPayload(t *testing.T) { + event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/terminal", "", "terminal", "", ""), "l\r\n") + if event.Type != TextInsertedEvent { + t.Fatalf("type = %q, want %q", event.Type, TextInsertedEvent) + } + if event.Payload != "l" { + t.Fatalf("payload = %q, want %q", event.Payload, "l") + } +} + +func TestNewEventWithPayloadPreservesInsertedSpace(t *testing.T) { + event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/editor", "", "text", "", ""), " ") + if event.Payload != " " { + t.Fatalf("payload = %q, want one space", event.Payload) + } +} + +func TestNewEventWithPayloadPreservesTextDelimiters(t *testing.T) { + snapshot := NewSnapshot("/accessible/editor", "", "text", "", "") + for input, want := range map[string]string{"\t": "\t", "\n": "\n", "\r": "\n", "\r\n": "\n"} { + event := NewEventWithPayload(TextInsertedEvent, snapshot, input) + if event.Payload != want { + t.Fatalf("payload for %q = %q, want %q", input, event.Payload, want) + } + } +} + +func TestSanitizeTextContextPreservesBoundariesAndLimitsLength(t *testing.T) { + context := sanitizeTextContext("First. Second. \r\n") + if context != "First. Second. \n\n" { + t.Fatalf("context = %q", context) + } + context = sanitizeTextContext(strings.Repeat("é", maxSnapshotFieldLength)) + if len(context) > maxSnapshotFieldLength || !strings.HasPrefix(context, "é") { + t.Fatalf("bounded context has %d bytes and invalid rune boundary", len(context)) + } +} + +func TestNewManagedDescendantEventPreservesOrigin(t *testing.T) { + source := NewSnapshot("/accessible/result", "Result", "table cell", "", "") + event := NewManagedDescendantEvent(SelectionChangedEvent, " /accessible/list\n", source) + if event.Source.Path != "/accessible/result" { + t.Fatalf("source path = %q", event.Source.Path) + } + if event.OriginPath != "/accessible/list" { + t.Fatalf("origin path = %q", event.OriginPath) + } +} +func TestBrowserChromeTabExcludesDocumentWidgets(t *testing.T) { + for _, toolkit := range []string{"Chromium", "Gecko", "WebKitGTK"} { + tab := Snapshot{Role: "page tab", ToolkitName: toolkit} + if !isBrowserChromeTab(tab, false) || isBrowserChromeTab(tab, true) { + t.Fatalf("chrome/document classification for %s", toolkit) + } + } + if isBrowserChromeTab(Snapshot{Role: "page tab", ToolkitName: "gtk"}, false) || + isBrowserChromeTab(Snapshot{Role: "button", ToolkitName: "Chromium"}, false) { + t.Fatal("ordinary application control classified as browser tab") + } +} diff --git a/internal/atspi/mouse_review_native.go b/internal/atspi/mouse_review_native.go new file mode 100644 index 0000000..e78b61f --- /dev/null +++ b/internal/atspi/mouse_review_native.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "context" + "strings" +) + +func (adapter *Adapter) clearMouseApplication() { + if adapter.mouseApplication != nil { + C.g_object_unref(C.gpointer(adapter.mouseApplication)) + } + adapter.mouseApplication = nil + adapter.mouseApplicationPID = 0 +} + +func (adapter *Adapter) mouseApplicationForPID(ctx context.Context, pid int) *C.AtspiAccessible { + if pid <= 0 || ctx.Err() != nil { + return nil + } + if adapter.mouseApplicationPID == pid && adapter.mouseApplication != nil { + return adapter.mouseApplication + } + adapter.clearMouseApplication() + desktop := C.atspi_get_desktop(0) + if desktop == nil { + return nil + } + defer C.g_object_unref(C.gpointer(desktop)) + count := min(int(C.atspi_accessible_get_child_count(desktop, nil)), 128) + for i := 0; i < count && ctx.Err() == nil; i++ { + app := C.atspi_accessible_get_child_at_index(desktop, C.gint(i), nil) + if app == nil { + continue + } + if int(C.atspi_accessible_get_process_id(app, nil)) == pid { + adapter.mouseApplication, adapter.mouseApplicationPID = app, pid + return app + } + C.g_object_unref(C.gpointer(app)) + } + return nil +} + +func nativeContainsPoint(source *C.AtspiAccessible, x, y int) bool { + component := C.atspi_accessible_get_component_iface(source) + if component == nil { + return false + } + defer C.g_object_unref(C.gpointer(component)) + var nativeErr *C.GError + inside := C.atspi_component_contains(component, C.gint(x), C.gint(y), C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) != 0 + if nativeErr != nil { + C.g_error_free(nativeErr) + return false + } + return inside +} + +func nativePointArea(source *C.AtspiAccessible) int64 { + component := C.atspi_accessible_get_component_iface(source) + if component == nil { + return 1 << 62 + } + defer C.g_object_unref(C.gpointer(component)) + var nativeErr *C.GError + rect := C.atspi_component_get_extents(component, C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + } + if rect == nil { + return 1 << 62 + } + defer C.g_free(C.gpointer(rect)) + if rect.width <= 0 || rect.height <= 0 { + return 1 << 62 + } + return int64(rect.width) * int64(rect.height) +} + +func nativeAccessibleAtPoint(source *C.AtspiAccessible, x, y int) *C.AtspiAccessible { + component := C.atspi_accessible_get_component_iface(source) + if component == nil { + return nil + } + defer C.g_object_unref(C.gpointer(component)) + var nativeErr *C.GError + child := C.atspi_component_get_accessible_at_point(component, C.gint(x), C.gint(y), C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + } + return child +} + +func nativePointText(ctx context.Context, source, window *C.AtspiAccessible, x, y int) (word, line string, lineStart, nativeOffset int) { + if C.atspi_accessible_get_role(source, nil) == C.ATSPI_ROLE_PASSWORD_TEXT { + return "", "", -1, -1 + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return "", "", -1, -1 + } + defer C.g_object_unref(C.gpointer(text)) + var nativeErr *C.GError + offset := C.atspi_text_get_offset_at_point(text, C.gint(x), C.gint(y), C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + nativeErr = nil + } + if offset < 0 { + component := C.atspi_accessible_get_component_iface(window) + if component != nil { + rect := C.atspi_component_get_extents(component, C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + nativeErr = nil + } + if rect != nil { + offset = C.atspi_text_get_offset_at_point(text, C.gint(x)-rect.x, C.gint(y)-rect.y, C.ATSPI_COORD_TYPE_WINDOW, &nativeErr) + C.g_free(C.gpointer(rect)) + if nativeErr != nil { + C.g_error_free(nativeErr) + nativeErr = nil + } + } + C.g_object_unref(C.gpointer(component)) + } + } + if offset < 0 { + // GTK text entries can report -1 at points inside displayed glyphs. + // Inspect a bounded number of character rectangles as a fallback. + count := min(int(C.atspi_text_get_character_count(text, nil)), 128) + for i := 0; i < count && ctx.Err() == nil; i++ { + rect := C.atspi_text_get_character_extents(text, C.gint(i), C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + nativeErr = nil + } + if rect == nil { + continue + } + inside := C.gint(x) >= rect.x && C.gint(x) < rect.x+rect.width && + C.gint(y) >= rect.y && C.gint(y) < rect.y+rect.height + C.g_free(C.gpointer(rect)) + if inside { + offset = C.gint(i) + break + } + } + } + if offset < 0 { + return "", "", -1, -1 + } + read := func(granularity C.AtspiTextGranularity) (string, int) { + var err *C.GError + value := C.atspi_text_get_string_at_offset(text, offset, granularity, &err) + if err != nil { + C.g_error_free(err) + } + if value == nil { + return "", -1 + } + defer C.g_free(C.gpointer(value)) + defer C.g_free(C.gpointer(value.content)) + return sanitizeSnapshotField(C.GoString(value.content)), int(value.start_offset) + } + word, _ = read(C.ATSPI_TEXT_GRANULARITY_WORD) + line, lineStart = read(C.ATSPI_TEXT_GRANULARITY_LINE) + return strings.TrimSpace(word), strings.TrimSpace(line), lineStart, int(offset) +} + +// nativeReviewAtPoint runs on the GLib owner. It retains only the current +// application; all window and descendant references are released per query. +func (adapter *Adapter) nativeReviewAtPoint(ctx context.Context, x, y, pid int) (PointReview, error) { + var result PointReview + app := adapter.mouseApplicationForPID(ctx, pid) + if app == nil || ctx.Err() != nil { + return result, ctx.Err() + } + var window *C.AtspiAccessible + bestArea := int64(1 << 62) + count := min(int(C.atspi_accessible_get_child_count(app, nil)), 64) + for i := 0; i < count && ctx.Err() == nil; i++ { + candidate := C.atspi_accessible_get_child_at_index(app, C.gint(i), nil) + if candidate == nil { + continue + } + if nativeContainsPoint(candidate, x, y) { + area := nativePointArea(candidate) + if window == nil || area <= bestArea { + if window != nil { + C.g_object_unref(C.gpointer(window)) + } + window, bestArea = candidate, area + continue + } + } + C.g_object_unref(C.gpointer(candidate)) + } + if window == nil || ctx.Err() != nil { + if window != nil { + C.g_object_unref(C.gpointer(window)) + } + return result, ctx.Err() + } + defer C.g_object_unref(C.gpointer(window)) + result.Window = nativeObjectID(window) + current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(window))) + defer func() { C.g_object_unref(C.gpointer(current)) }() + for depth := 0; depth < 24 && ctx.Err() == nil; depth++ { + child := nativeAccessibleAtPoint(current, x, y) + if child == nil { + break + } + if nativeObjectID(child) == nativeObjectID(current) || !nativeContainsPoint(child, x, y) { + C.g_object_unref(C.gpointer(child)) + break + } + C.g_object_unref(C.gpointer(current)) + current = child + } + if ctx.Err() != nil { + return PointReview{}, ctx.Err() + } + result.Snapshot = snapshotFromNativeWithOptions(current, false, false, false, false, true) + if result.Snapshot.Defunct || result.Snapshot.Role == "password text" { + return result, nil + } + result.Word, result.Line, result.LineStart, result.Offset = nativePointText(ctx, current, window, x, y) + return result, nil +} diff --git a/internal/atspi/mouse_review_native_test.go b/internal/atspi/mouse_review_native_test.go new file mode 100644 index 0000000..707e21a --- /dev/null +++ b/internal/atspi/mouse_review_native_test.go @@ -0,0 +1,81 @@ +// 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" + "strconv" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +func TestNativeMouseReviewPointerAndHitTest(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_MOUSE_REVIEW") != "1" { + t.Skip("requires private GTK and Xorg fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + pointer := xorg.OpenPointerReader() + if pointer == nil { + t.Fatal("X pointer reader unavailable") + } + defer pointer.Close() + for _, item := range []struct { + name, role string + protected bool + }{ + {"Caret review probe", "text", false}, + {"Protected text probe", "password text", true}, + } { + ref := findNamedReference(t, tree, item.name) + if !ref.Valid() { + t.Fatalf("%s absent", item.name) + } + geometry, err := tree.Geometry(ref) + if err != nil || !geometry.BoundsKnown { + t.Fatalf("%s geometry: %+v, %v", item.name, geometry, err) + } + x := int(geometry.Bounds.X + min(25, geometry.Bounds.Width/2)) + y := int(geometry.Bounds.Y + geometry.Bounds.Height/2) + if out, err := exec.Command("xdotool", "mousemove", strconv.Itoa(x), strconv.Itoa(y)).CombinedOutput(); err != nil { + t.Fatalf("move pointer: %s: %v", out, err) + } + position := pointer.Read() + if !position.Known || position.PID <= 0 || position.X != x || position.Y != y { + t.Fatalf("%s pointer = %+v", item.name, position) + } + point, err := adapter.ReviewAtPoint(ctx, position.X, position.Y, position.PID) + if err != nil { + t.Fatal(err) + } + if point.Snapshot.Role != item.role || point.Snapshot.Name != item.name || !point.Window.Valid() { + t.Fatalf("%s hit test = %+v", item.name, point) + } + if item.protected && (point.Word != "" || point.Line != "") { + t.Fatalf("password content exposed: %+v", point) + } + if !item.protected && (point.Line == "" || point.Offset < 0) { + t.Fatalf("entry text missing: %+v", point) + } + } +} diff --git a/internal/atspi/ocr_geometry.go b/internal/atspi/ocr_geometry.go new file mode 100644 index 0000000..4f41cff --- /dev/null +++ b/internal/atspi/ocr_geometry.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "errors" + "image" + "math" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +var ErrOCRGeometry = errors.New("OCR source bounds unavailable") + +// OCRGeometry is an owned snapshot. ProviderBounds/WindowBounds use AT-SPI +// coordinates; ScreenBounds uses physical X11 root pixels. It authorizes +// capture only, never activation. Foreground must still match at capture time. +type OCRGeometry struct { + Object ObjectID + ProviderBounds, WindowBounds Rectangle + ScreenBounds image.Rectangle + Foreground ForegroundState +} + +// Match the source's own fresh top-level window, allowing the same proportional +// scaling supported by the capture contract. Origins must match either the +// physical or uniformly scaled root. Unknown origins are rejected. Use measured +// per-axis ratios, not DPI. +func ocr_screen_bounds(bounds, window Rectangle, client xorg.Foreground) (image.Rectangle, error) { + if !bounds.Valid() || !window.Valid() || !client.Known || client.Window == 0 { + return image.Rectangle{}, ErrOCRGeometry + } + inner := Rectangle{int32(client.X), int32(client.Y), int32(client.Width), int32(client.Height)} + outer := Rectangle{int32(client.OuterX), int32(client.OuterY), int32(client.OuterWidth), int32(client.OuterHeight)} + matched := Rectangle{} + // Exact matches take precedence over a proportional decoration match. + for _, candidate := range []Rectangle{inner, outer} { + if window == candidate { + matched = candidate + break + } + } + if !matched.Valid() { + for _, candidate := range []Rectangle{inner, outer} { + if ocr_window_matches(window, candidate) { + if matched.Valid() && matched != candidate { + return image.Rectangle{}, ErrOCRGeometry + } + matched = candidate + } + } + } + if !matched.Valid() { + return image.Rectangle{}, ErrOCRGeometry + } + clipped, ok := bounds.Intersection(window) + if !ok { + return image.Rectangle{}, ErrOCRGeometry + } + + // Use exact integer edge ratios. Provider windows can exceed the OCR image + // pixel cap while the chosen object/zone remains a small valid capture. + map_edge := func(value int64, origin, extent, screenOrigin, screenExtent int32, upper bool) int { + numerator := (value - int64(origin)) * int64(screenExtent) + if upper { + numerator += int64(extent) - 1 + } + return int(int64(screenOrigin) + numerator/int64(extent)) + } + mapped := image.Rect(map_edge(int64(clipped.X), window.X, window.Width, matched.X, matched.Width, false), + map_edge(int64(clipped.Y), window.Y, window.Height, matched.Y, matched.Height, false), + map_edge(int64(clipped.X)+int64(clipped.Width), window.X, window.Width, matched.X, matched.Width, true), + map_edge(int64(clipped.Y)+int64(clipped.Height), window.Y, window.Height, matched.Y, matched.Height, true)) + if mapped.Empty() { + return image.Rectangle{}, ErrOCRGeometry + } + + return mapped, nil +} + +// GTK can scale the root origin as well as dimensions; Chromium may retain +// a physical root origin. Admit these two measured contracts only. Other +// monitor-dependent origin conventions need separate evidence. +func ocr_window_matches(provider, native Rectangle) bool { + if !provider.Valid() || !native.Valid() { + return false + } + scaleX, scaleY := float64(native.Width)/float64(provider.Width), float64(native.Height)/float64(provider.Height) + if scaleX < 0.5 || scaleX > 3 || scaleY < 0.5 || scaleY > 3 || math.Abs(scaleX-scaleY) > 0.01*math.Max(scaleX, scaleY) { + return false + } + return provider.X == native.X && provider.Y == native.Y || + math.Abs(float64(provider.X)*scaleX-float64(native.X)) <= 1 && + math.Abs(float64(provider.Y)*scaleY-float64(native.Y)) <= 1 +} diff --git a/internal/atspi/ocr_geometry_test.go b/internal/atspi/ocr_geometry_test.go new file mode 100644 index 0000000..082c9e0 --- /dev/null +++ b/internal/atspi/ocr_geometry_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "image" + "math" + "testing" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +func TestOCRProviderCoordinateConversion(t *testing.T) { + for _, test := range []struct { + name string + window, object Rectangle + client xorg.Foreground + want image.Rectangle + fail bool + }{ + {name: "physical", window: Rectangle{40, 30, 300, 200}, object: Rectangle{50, 45, 80, 50}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 30, Width: 300, Height: 200}, want: image.Rect(50, 45, 130, 95)}, + {name: "scaled provider", window: Rectangle{40, 30, 150, 100}, object: Rectangle{45, 35, 40, 25}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 30, Width: 300, Height: 200}, want: image.Rect(50, 40, 130, 90)}, + {name: "round outward", window: Rectangle{40, 30, 200, 100}, object: Rectangle{41, 31, 1, 1}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 30, Width: 301, Height: 151}, want: image.Rect(41, 31, 44, 34)}, + {name: "negative clip", window: Rectangle{-200, -100, 300, 200}, object: Rectangle{-220, -110, 50, 40}, client: xorg.Foreground{Known: true, Window: 1, X: -200, Y: -100, Width: 300, Height: 200}, want: image.Rect(-200, -100, -170, -70)}, + {name: "decorations", window: Rectangle{40, 30, 300, 220}, object: Rectangle{50, 55, 80, 50}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 50, Width: 300, Height: 200, OuterX: 40, OuterY: 30, OuterWidth: 300, OuterHeight: 220}, want: image.Rect(50, 55, 130, 105)}, + {name: "large window small object", window: Rectangle{0, 0, 7680, 4320}, object: Rectangle{20, 20, 40, 30}, client: xorg.Foreground{Known: true, Window: 1, Width: 7680, Height: 4320}, want: image.Rect(20, 20, 60, 50)}, + {name: "unknown origin", window: Rectangle{math.MinInt32, 0, 300, 200}, object: Rectangle{0, 0, 10, 10}, fail: true}, + {name: "disjoint", window: Rectangle{40, 30, 300, 200}, object: Rectangle{400, 0, 10, 10}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 30, Width: 300, Height: 200}, fail: true}, + {name: "nonuniform scale", window: Rectangle{40, 30, 300, 200}, object: Rectangle{50, 45, 80, 50}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 30, Width: 600, Height: 200}, fail: true}, + {name: "scaled root origin", window: Rectangle{20, 15, 150, 100}, object: Rectangle{25, 20, 40, 25}, client: xorg.Foreground{Known: true, Window: 1, X: 40, Y: 30, Width: 300, Height: 200}, want: image.Rect(50, 40, 130, 90)}, + {name: "ambiguous decoration scale", window: Rectangle{0, 0, 100, 100}, object: Rectangle{0, 0, 20, 20}, client: xorg.Foreground{Known: true, Window: 1, Width: 200, Height: 200, OuterWidth: 202, OuterHeight: 202}, fail: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := ocr_screen_bounds(test.object, test.window, test.client) + if test.fail { + if err == nil { + t.Fatalf("accepted ambiguous bounds: %v", got) + } + return + } + if err != nil || got != test.want { + t.Fatalf("got %v %v, want %v", got, err, test.want) + } + }) + } +} diff --git a/internal/atspi/ocr_native_test.go b/internal/atspi/ocr_native_test.go new file mode 100644 index 0000000..cb3f639 --- /dev/null +++ b/internal/atspi/ocr_native_test.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "encoding/json" + "image" + "image/color" + "os" + "os/exec" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +func TestNativeOCRProviderGeometry(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR_GEOMETRY") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + executable := os.Getenv("CTHULHU_OCR_CAPTURE_BINARY") + if executable == "" { + t.Fatal("set freshly built CTHULHU_OCR_CAPTURE_BINARY") + } + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + for _, scale := range []string{"1", "2"} { + t.Run("scale"+scale, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/ocr_geometry_fixture.py") + fixture.Env = append(os.Environ(), "GDK_SCALE="+scale, "NO_AT_BRIDGE=0", "GTK_MODULES=gail:atk-bridge") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + type state struct { + Window uint64 + Target [4]int + Focus uint64 + } + read := func() state { + t.Helper() + if !scanner.Scan() { + t.Fatal("fixture did not respond") + } + var s state + if err = json.Unmarshal(scanner.Bytes(), &s); err != nil { + t.Fatal(err) + } + return s + } + initial := read() + action := func(name string) state { + t.Helper() + data, _ := json.Marshal(map[string]string{"action": name}) + if _, err = stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + return read() + } + var tree *AccessibleTree + var ref ObjectReference + for !ref.Valid() { + tree, err = adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + ref = findNamedReference(t, tree, "Painted target") + if !ref.Valid() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(10 * time.Millisecond): + } + } + } + geometry, err := adapter.CaptureOCRGeometry(ctx, ref.ID(), nil) + expected := image.Rect(initial.Target[0], initial.Target[1], initial.Target[2], initial.Target[3]) + if err != nil || geometry.ScreenBounds != expected || geometry.Foreground.Client.Window != initial.Window { + provider, _ := tree.Geometry(ref) + t.Fatalf("geometry=%+v err=%v want=%v provider=%+v foreground=%+v", geometry, err, expected, provider, adapter.Foreground()) + } + result, err := ocr.NewCapturer(executable).Capture(ctx, ocr.CaptureRequest{Kind: ocr.ObjectSource, Bounds: geometry.ScreenBounds, Foreground: initial.Window, Generation: 1}) + if err != nil { + t.Fatal(err) + } + for _, point := range []image.Point{image.Pt(0, 0), image.Pt(result.Pixels.Bounds().Dx()-1, result.Pixels.Bounds().Dy()-1)} { + if got := color.RGBAModel.Convert(result.Pixels.At(point.X, point.Y)); got != (color.RGBA{51, 102, 153, 255}) { + t.Fatalf("mapped pixel %v = %v", point, got) + } + } + zone := geometry.ProviderBounds + zone.Width /= 2 + zone.Height /= 2 + part, err := adapter.CaptureOCRGeometry(ctx, ref.ID(), &zone) + if err != nil || part.ScreenBounds.Dx() != expected.Dx()/2 || part.ScreenBounds.Dy() != expected.Dy()/2 { + t.Fatalf("zone: %+v %v", part, err) + } + invalid := zone + invalid.X -= 10 + if _, err = adapter.CaptureOCRGeometry(ctx, ref.ID(), &invalid); err == nil { + t.Fatal("outside zone accepted") + } + after := action("state") + if after.Focus != initial.Focus { + t.Fatal("geometry/capture stole focus") + } + protected := findNamedReference(t, tree, "Protected target") + if !protected.Valid() { + t.Fatal("protected entry missing") + } + if _, err = adapter.CaptureOCRGeometry(ctx, protected.ID(), nil); err != nil { + t.Fatalf("masked password capture geometry unavailable: %v", err) + } + action("hide") + if _, err = adapter.CaptureOCRGeometry(ctx, ref.ID(), nil); err == nil { + t.Fatal("hidden source accepted") + } + }) + } +} diff --git a/internal/atspi/position_native_test.go b/internal/atspi/position_native_test.go new file mode 100644 index 0000000..ec03722 --- /dev/null +++ b/internal/atspi/position_native_test.go @@ -0,0 +1,65 @@ +// 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 TestNativeBrowserSetPositions(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 adapter.Stop(context.Background()) + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + position, size int + }{ + {"First item", 1, 3}, {"Middle item", 2, 3}, {"Last item", 3, 3}, + {"Virtual item", 501, 1001}, {"Unknown size", 0, 0}, + {"Nested tree item", 2, 4}, + {"First menu item", 1, 2}, {"Second menu item", 2, 2}, + {"First radio", 1, 2}, {"Second radio", 2, 2}, + } { + snapshot, err := tree.Resolve(findNamedReference(t, tree, test.name)) + if err != nil || snapshot.PositionInSet != test.position || snapshot.SetSize != test.size { + t.Errorf("%s (%s): position %d of %d, want %d of %d, err=%v", test.name, snapshot.Role, + snapshot.PositionInSet, snapshot.SetSize, test.position, test.size, err) + } + } + // Snapshot capture for a focused event must carry provider metadata too, + // rather than only the document-tree path used by browse navigation. + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil { + t.Fatalf("focus virtual item: %v %s", err, out) + } + for { + select { + case event := <-adapter.Events(): + if event.Type != FocusedStateEvent || event.Detail1 != 1 || event.Source.Name != "Virtual item" { + continue + } + if event.Source.PositionInSet != 501 || event.Source.SetSize != 1001 { + t.Fatalf("focus lost set position: %+v", event.Source) + } + return + case <-ctx.Done(): + t.Fatal("virtual item focus event missing") + } + } +} diff --git a/internal/atspi/preferences_native_test.go b/internal/atspi/preferences_native_test.go new file mode 100644 index 0000000..2585103 --- /dev/null +++ b/internal/atspi/preferences_native_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func TestNativePreferencesDropdownEscape(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PREFERENCES") != "1" { + t.Skip("requires private Xvfb/AT-SPI session and preferences binary") + } + largeCatalog := os.Getenv("CTHULHU_PREFS_LARGE_CATALOG") == "1" + if largeCatalog { + t.Setenv("LC_ALL", "en_US.UTF-8") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + wmConfig := filepath.Join(t.TempDir(), "i3.config") + if err := os.WriteFile(wmConfig, []byte("font pango:monospace 10\nipc-socket "+filepath.Join(filepath.Dir(wmConfig), "i3.sock")+"\n"), 0600); err != nil { + t.Fatal(err) + } + wm := exec.CommandContext(ctx, "i3", "-c", wmConfig) + wm.Env = append(os.Environ(), "I3SOCK="+filepath.Join(filepath.Dir(wmConfig), "i3.sock")) + wm.Stderr = os.Stderr + wm.Stdout = os.Stderr + if err := wm.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = wm.Process.Kill() + _ = wm.Wait() + }() + for { + output, _ := exec.CommandContext(ctx, "xprop", "-root", "_NET_SUPPORTING_WM_CHECK").Output() + if strings.Contains(string(output), "0x") { + break + } + select { + case <-ctx.Done(): + t.Fatal("private window manager did not start") + case <-time.After(20 * time.Millisecond): + } + } + adapter := New() + adapter.SetCaptureSoundGeometry(false) + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + command := exec.CommandContext(ctx, os.Getenv("CTHULHU_BINARY"), "--preferences-ui") + in, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + helperDone := make(chan error, 1) + go func() { helperDone <- command.Wait() }() + helperJoined := false + defer func() { + in.Close() + _ = command.Process.Kill() + if !helperJoined { + <-helperDone + } + }() + settings := config.DefaultSettings() + settings.DefaultVoice.Family = &config.VoiceFamily{Name: "Test voice", Lang: "en"} + if largeCatalog { + settings.DefaultVoice.Family = nil + } + initial := preferences.Response{Values: preferences.FromSettings(settings), + CanSave: true, Accepted: true, Message: "Preferences test ready.", + Catalog: speech.Catalog{"": {{Name: "Test voice", Language: "en"}}, "test-module": nil}} + if largeCatalog { + // The helper inherits the test's locale; only 100 of the 6,000 + // eSpeak voices should be built into its Person menu. + for i := 0; i < 6000; i++ { + language := "de" + if i < 100 { + language = "en" + } + initial.Catalog["espeak-ng"] = append(initial.Catalog["espeak-ng"], + speech.Voice{Name: fmt.Sprintf("Test voice %d", i), Language: language}) + } + } + if err := json.NewEncoder(in).Encode(initial); err != nil { + t.Fatal(err) + } + wait_event := func(timeout time.Duration, match func(Event) bool) Event { + t.Helper() + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case event := <-adapter.Events(): + t.Logf("%s %s %q capture=%s", event.Type, event.Source.Role, event.Source.Name, event.SnapshotDuration) + if event.Type == FocusedStateEvent && event.Detail1 == 1 { + // Match the running reader's browser-mode classification. + // Without these fresh ancestry queries, stale cached focus + // hides the no-focus interval when the popup closes. + _, _ = adapter.CaptureDocumentContext(ctx, event.Source.ID()) + } + if match(event) { + return event + } + case <-timer.C: + t.Fatal("expected preferences event missing") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + focused := func(name string) func(Event) bool { + return func(event Event) bool { + return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == name + } + } + wait_event(5*time.Second, func(event Event) bool { return event.Type == WindowActivatedEvent }) + xdotool := func(args ...string) string { + t.Helper() + output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput() + if err != nil { + t.Fatalf("xdotool %v: %v: %s", args, err, output) + } + return strings.TrimSpace(string(output)) + } + window := xdotool("search", "--onlyvisible", "--name", "^Cthulhu Preferences$") + xdotool("windowfocus", window) + xdotool("key", "--clearmodifiers", "ctrl+Page_Down", "ctrl+Page_Down") + wait_event(5*time.Second, focused("Speech")) + xdotool("key", "Tab", "Tab") + wait_event(5*time.Second, focused("Speech system")) + xdotool("key", "alt+Down") + wait_event(5*time.Second, focused("Speech Dispatcher")) + started := time.Now() + xdotool("key", "Escape") + wait_event(1500*time.Millisecond, focused("Speech system")) + t.Logf("dropdown Escape restored focus in %s", time.Since(started)) + xdotool("key", "Tab") + wait_event(1500*time.Millisecond, focused("Speech synthesizer")) + xdotool("key", "alt+Down") + wait_event(5*time.Second, focused("Default")) + xdotool("key", "Escape") + wait_event(1500*time.Millisecond, focused("Speech synthesizer")) + if largeCatalog { + xdotool("key", "Down") + } + xdotool("key", "Tab") + wait_event(1500*time.Millisecond, focused("Language")) + if largeCatalog { + xdotool("key", "Tab") + wait_event(1500*time.Millisecond, focused("Person")) + xdotool("key", "alt+Down") + wait_event(1500*time.Millisecond, focused("Default")) + xdotool("key", "Escape") + wait_event(1500*time.Millisecond, focused("Person")) + } + xdotool("key", "Escape") + select { + case err := <-helperDone: + helperJoined = true + if err != nil { + t.Fatal(err) + } + case <-time.After(1500 * time.Millisecond): + t.Fatal("Escape did not close preferences promptly") + } +} diff --git a/internal/atspi/read_all_native.go b/internal/atspi/read_all_native.go new file mode 100644 index 0000000..9f1ff3a --- /dev/null +++ b/internal/atspi/read_all_native.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#cgo pkg-config: atspi-2 glib-2.0 gobject-2.0 +#include +*/ +import "C" + +import ( + "context" + "errors" +) + +func (adapter *Adapter) ReadFocusedText(ctx context.Context, focus ObjectID) (DocumentContent, error) { + result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, readFocusedText: true}) + return result.content, err +} + +func (adapter *Adapter) SetFocusedTextCaret(ctx context.Context, focus ObjectID, offset int) error { + _, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, setFocusedCaret: true, positionOffset: offset}) + return err +} + +func (adapter *Adapter) setNativeFocusedTextCaret(ctx context.Context, focus ObjectID, offset int) error { + if err := ctx.Err(); err != nil { + return err + } + if !adapter.nativeFocusIs(focus) || offset < 0 { + return ErrStaleObject + } + source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source + if C.atspi_accessible_get_role(source, nil) == C.ATSPI_ROLE_PASSWORD_TEXT { + return ErrNoDocumentCaret + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return ErrNoDocumentCaret + } + defer C.g_object_unref(C.gpointer(text)) + adapter.navigationEcho = map[ObjectID]uint8{focus: 7} + var nativeErr *C.GError + placed := C.atspi_text_set_caret_offset(text, C.gint(offset), &nativeErr) + if nativeErr != nil { + err := nativeError("place read all caret", nativeErr) + C.g_error_free(nativeErr) + return err + } + if placed == 0 { + delete(adapter.navigationEcho, focus) + return ErrNoDocumentCaret + } + return nil +} + +func (adapter *Adapter) readNativeFocusedText(ctx context.Context, focus ObjectID) (DocumentContent, error) { + if err := ctx.Err(); err != nil { + return DocumentContent{}, err + } + if !adapter.nativeFocusIs(focus) { + return DocumentContent{}, ErrStaleObject + } + source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source + snapshot := snapshotFromNativeWithCaret(source, false, false, false, false) + content := DocumentContent{Snapshot: snapshot} + if snapshot.Role == "password text" { + return content, nil + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return content, nil + } + defer C.g_object_unref(C.gpointer(text)) + var nativeErr *C.GError + count := int(C.atspi_text_get_character_count(text, &nativeErr)) + if nativeErr != nil { + err := nativeError("read all character count", nativeErr) + C.g_error_free(nativeErr) + return DocumentContent{}, err + } + offset := int(C.atspi_text_get_caret_offset(text, &nativeErr)) + if nativeErr != nil { + err := nativeError("read all caret offset", nativeErr) + C.g_error_free(nativeErr) + return DocumentContent{}, err + } + offset = max(0, offset) + if snapshot.Role == "terminal" && count > 0 { + terminal := nativeReviewText{text: text, terminal: true} + terminalCount, err := terminal.count(ctx) + if err != nil { + return DocumentContent{}, err + } + count = terminalCount + if count > 0 { + // A terminal caret commonly sits after the prompt's last character. + // Python Say All reads its containing line, including that prompt, + // before advancing. Keep editable controls' exact caret behavior. + line, err := terminal.line(ctx, min(offset, count-1)) + if err != nil { + return DocumentContent{}, err + } + if line.Start < 0 || line.Start > offset || line.End <= line.Start || line.End > count { + return DocumentContent{}, errors.New("read all: invalid terminal line offsets") + } + offset = line.Start + } + } + if count-offset > 1<<20 { + return DocumentContent{}, errors.New("read all: text exceeds speech capture limit") + } + if offset >= count { + content.Snapshot.Name, content.Snapshot.Text = "", "" + return content, nil + } + value := C.atspi_text_get_text(text, C.gint(offset), C.gint(count), &nativeErr) + if value != nil { + defer C.g_free(C.gpointer(value)) + } + if nativeErr != nil { + err := nativeError("read all text", nativeErr) + C.g_error_free(nativeErr) + return DocumentContent{}, err + } + content.Start, content.End, content.Text = offset, count, C.GoString(value) + return content, ctx.Err() +} diff --git a/internal/atspi/review.go b/internal/atspi/review.go new file mode 100644 index 0000000..8e77fad --- /dev/null +++ b/internal/atspi/review.go @@ -0,0 +1,95 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" +) + +var ErrNoReviewWindow = errors.New("focused object has no review window") + +// ReviewSnapshot owns one window's nodes and their generation-checked +// references. Keeping references alongside nodes prevents a reused object ID +// from inheriting an independent review position from a removed object. +type ReviewSnapshot struct { + Nodes []TreeNode + References map[ObjectID]ObjectReference +} + +// ReviewWindow copies the focused menu, dialog, or window in tree order under +// one read lock. A consumer must not assemble layout from separately locked +// getters: replacement between getters could mix two different captures. +// Stale descendants fail the whole operation rather than yielding partial +// screen content. Call this on the review worker, never the input/event loop. +func (tree *AccessibleTree) ReviewWindow(ctx context.Context, focus ObjectID) (ReviewSnapshot, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + if err := ctx.Err(); err != nil { + return ReviewSnapshot{}, err + } + entry, ok := tree.nodes[focus] + if !ok { + return ReviewSnapshot{}, ErrObjectNotFound + } + root := focus + for { + switch entry.snapshot.Role { + case "menu", "popup menu", "dialog", "alert", "frame", "window": + goto found + } + if !entry.hasParent { + return ReviewSnapshot{}, ErrNoReviewWindow + } + root = entry.parent + entry = tree.nodes[root] + } +found: + var nodes []TreeNode + references := make(map[ObjectID]ObjectReference) + pending := []ObjectID{root} + for len(pending) > 0 { + if err := ctx.Err(); err != nil { + return ReviewSnapshot{}, err + } + id := pending[len(pending)-1] + pending = pending[:len(pending)-1] + entry, ok := tree.nodes[id] + if !ok { + return ReviewSnapshot{}, ErrStaleObject + } + if entry.childrenInvalid { + return ReviewSnapshot{}, ErrIncompleteTree + } + if entry.geometryInvalid { + return ReviewSnapshot{}, ErrStaleGeometry + } + if entry.textInvalid { + return ReviewSnapshot{}, ErrStaleText + } + text := entry.text + text.Ranges = append([]TextRange(nil), text.Ranges...) + nodes = append(nodes, TreeNode{Snapshot: entry.snapshot, Geometry: entry.geometry, + Text: text, Children: append([]ObjectID(nil), entry.children...)}) + references[id] = newObjectReference(id, entry) + for i := len(entry.children) - 1; i >= 0; i-- { + pending = append(pending, entry.children[i]) + } + } + return ReviewSnapshot{Nodes: nodes, References: references}, nil +} diff --git a/internal/atspi/review_test.go b/internal/atspi/review_test.go new file mode 100644 index 0000000..df6e157 --- /dev/null +++ b/internal/atspi/review_test.go @@ -0,0 +1,99 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" + "testing" +) + +func reviewTree(t *testing.T) (*AccessibleTree, ObjectID) { + t.Helper() + id := func(path string) ObjectID { return NewObjectID(":1.42", path) } + node := func(path, role string, children ...ObjectID) TreeNode { + s := NewSnapshot(path, path, role, "", "") + s.BusName = ":1.42" + return TreeNode{Snapshot: s, Children: children} + } + tree, err := NewAccessibleTree(id("/desktop"), []TreeNode{ + node("/desktop", "desktop frame", id("/window"), id("/other")), + node("/window", "frame", id("/button"), id("/dialog")), + node("/button", "push button"), + node("/dialog", "dialog", id("/entry")), + node("/entry", "entry"), + node("/other", "frame"), + }) + if err != nil { + t.Fatal(err) + } + return tree, id("/button") +} + +func TestReviewWindowScopeAndOwnership(t *testing.T) { + tree, focus := reviewTree(t) + snapshot, err := tree.ReviewWindow(context.Background(), focus) + nodes := snapshot.Nodes + if err != nil { + t.Fatal(err) + } + if len(nodes) != 4 || nodes[0].Snapshot.Path != "/window" || nodes[1].Snapshot.Path != "/button" || nodes[3].Snapshot.Path != "/entry" { + t.Fatalf("unexpected window: %#v", nodes) + } + nodes[0].Children[0] = ObjectID{} + focus.Path = "/entry" + snapshot, err = tree.ReviewWindow(context.Background(), focus) + nodes = snapshot.Nodes + if err != nil || len(nodes) != 2 || nodes[0].Snapshot.Path != "/dialog" { + t.Fatalf("dialog scope: %#v, %v", nodes, err) + } + focus.Path = "/button" + if _, err := tree.ReviewWindow(context.Background(), focus); err != nil { + t.Fatal("caller mutated tree:", err) + } +} + +func TestReviewWindowRejectsStaleDataAndCancellation(t *testing.T) { + for _, tc := range []struct { + name string + invalidate func(*AccessibleTree, ObjectID) + want error + }{ + {"children", func(tree *AccessibleTree, id ObjectID) { tree.InvalidateChildren(id) }, ErrIncompleteTree}, + {"geometry", func(tree *AccessibleTree, id ObjectID) { tree.InvalidateGeometry(id) }, ErrStaleGeometry}, + {"text", func(tree *AccessibleTree, id ObjectID) { tree.InvalidateText(id) }, ErrStaleText}, + } { + t.Run(tc.name, func(t *testing.T) { + tree, focus := reviewTree(t) + tc.invalidate(tree, focus) + if snapshot, err := tree.ReviewWindow(context.Background(), focus); snapshot.Nodes != nil || !errors.Is(err, tc.want) { + t.Fatalf("nodes=%v err=%v", snapshot.Nodes, err) + } + }) + } + tree, focus := reviewTree(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := tree.ReviewWindow(ctx, focus); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + focus.Path = "/desktop" + if _, err := tree.ReviewWindow(context.Background(), focus); !errors.Is(err, ErrNoReviewWindow) { + t.Fatal(err) + } +} diff --git a/internal/atspi/session_native_test.go b/internal/atspi/session_native_test.go new file mode 100644 index 0000000..31c55f5 --- /dev/null +++ b/internal/atspi/session_native_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "os" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +// A private X server represents an available Xwayland connection here. It does +// not establish compositor support, only that X metadata cannot veto AT-SPI. +func TestNativeWaylandForegroundPolicy(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_WAYLAND_POLICY") != "1" { + t.Skip("requires private Xvfb and D-Bus") + } + reader := xorg.OpenForegroundReader() + if reader == nil { + t.Fatal("private X11 display unavailable") + } + reader.Close() + t.Setenv("XDG_SESSION_TYPE", "wayland") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + if err := adapter.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + if adapter.Foreground().Client.Known { + t.Fatal("X11 foreground used as Wayland desktop authority") + } + if !adapter.matchesForeground(nil, ObjectID{}) { + t.Fatal("missing X11 foreground rejected Wayland accessibility") + } +} diff --git a/internal/atspi/speech_metadata_native.go b/internal/atspi/speech_metadata_native.go new file mode 100644 index 0000000..e7910e2 --- /dev/null +++ b/internal/atspi/speech_metadata_native.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import ( + "strings" + "time" + "unsafe" +) + +func native_mnemonic(source *C.AtspiAccessible) string { + action := C.atspi_accessible_get_action_iface(source) + if action == nil { + return "" + } + defer C.g_object_unref(C.gpointer(action)) + if C.atspi_action_get_n_actions(action, nil) <= 0 { + return "" + } + binding := C.atspi_action_get_key_binding(action, 0, nil) + defer C.g_free(C.gpointer(binding)) + parts := strings.Split(C.GoString(binding), ";") + if len(parts) == 0 { + return "" + } + // AT-SPI's first field is the mnemonic. Empty mnemonic fields must not + // turn an unrelated accelerator into a fabricated mnemonic. + return sanitizeSnapshotField(parts[0]) +} + +func native_misspelled(text *C.AtspiText, offset int) bool { + run := native_text_attribute_run(text, offset) + return TextAttributesMisspelled(run.Values) +} + +func native_text_attribute_run(text *C.AtspiText, offset int) TextAttributeRun { + if offset < 0 { + return TextAttributeRun{} + } + var start, end C.gint + attributes := C.atspi_text_get_attribute_run(text, C.gint(offset), 0, &start, &end, nil) + if attributes == nil { + return TextAttributeRun{} + } + defer C.g_hash_table_unref(attributes) + run := TextAttributeRun{Start: int(start), End: int(end), Values: make(map[string]string)} + for _, key := range spokenCharacterAttributeKeys { + name := C.CString(key) + value := C.g_hash_table_lookup(attributes, C.gconstpointer(unsafe.Pointer(name))) + C.free(unsafe.Pointer(name)) + if value != nil { + run.Values[key] = sanitizeSnapshotField(C.GoString((*C.char)(value))) + } + } + return run +} + +// Each provider request can block. Stop after a bounded number of runs and a +// short deadline; the caret run is still available if a provider is slow. +func native_text_attribute_runs(text *C.AtspiText, start, end int, caret TextAttributeRun) []TextAttributeRun { + if start < 0 || end <= start { + return nil + } + deadline := time.Now().Add(60 * time.Millisecond) + var runs []TextAttributeRun + for offset := start; offset < end && len(runs) < 32 && time.Now().Before(deadline); { + run := caret + if offset < run.Start || offset >= run.End { + run = native_text_attribute_run(text, offset) + } + if run.End <= offset { + break + } + runs = append(runs, run) + offset = run.End + } + return runs +} + +// Queries stay on the GLib owner. This copies only container metadata, bounded +// by depth and time, and never scans children or retains native references. +func native_speech_context(source *C.AtspiAccessible) ([]Snapshot, []Snapshot) { + result := make([]Snapshot, 0) + brailleContext := make([]Snapshot, 0) + speechOpen := true + current := C.atspi_accessible_get_parent(source, nil) + deadline := time.Now().Add(100 * time.Millisecond) + for depth := 0; current != nil && depth < 16 && time.Now().Before(deadline); depth++ { + roleName := C.atspi_accessible_get_role_name(current, nil) + role := C.GoString(roleName) + C.g_free(C.gpointer(roleName)) + if role == "application" || role == "frame" || role == "window" || role == "dialog" { + speechOpen = false + } + id := nativeObjectID(current) + item := Snapshot{BusName: id.BusName, Path: id.Path, Role: role} + name := C.atspi_accessible_get_name(current, nil) + item.Name = sanitizeSnapshotField(C.GoString(name)) + C.g_free(C.gpointer(name)) + if role == "application" { + brailleContext = append(brailleContext, item) + break + } + if speechOpen { + switch role { + case "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) + item.ChildCount, item.ChildCountKnown = int(count), childError == nil && count >= 0 + if childError != nil { + C.g_error_free(childError) + } + } + if role == "section" || role == "form" || role == "landmark" || role == "table" { + attributes := C.atspi_accessible_get_attributes(current, nil) + if attributes != nil { + for _, field := range []struct { + key string + target *string + }{{"xml-roles", &item.XMLRoles}, {"tag", &item.HTMLTag}} { + key := C.CString(field.key) + *field.target = sanitizeSnapshotField(C.GoString((*C.char)(C.g_hash_table_lookup(attributes, C.gconstpointer(unsafe.Pointer(key)))))) + C.free(unsafe.Pointer(key)) + } + key := C.CString("layout-guess") + item.LayoutTable = C.GoString((*C.char)(C.g_hash_table_lookup(attributes, C.gconstpointer(unsafe.Pointer(key))))) == "true" + C.free(unsafe.Pointer(key)) + C.g_hash_table_unref(attributes) + } + } + result = append(result, item) + } + } + brailleContext = append(brailleContext, item) + parent := C.atspi_accessible_get_parent(current, nil) + C.g_object_unref(C.gpointer(current)) + current = parent + } + if current != nil { + C.g_object_unref(C.gpointer(current)) + } + return result, brailleContext +} diff --git a/internal/atspi/speech_metadata_native_test.go b/internal/atspi/speech_metadata_native_test.go new file mode 100644 index 0000000..7c51857 --- /dev/null +++ b/internal/atspi/speech_metadata_native_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestNativeSpeechMetadata(t *testing.T) { + if os.Getenv("CTHULHU_SPEECH_METADATA") != "1" { + t.Skip("requires private GTK speech fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := New() + adapter.SetCaptureTextAttributes(true) + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + for _, probe := range []struct{ key, name string }{{"F9", "Mnemonic probe"}, {"F10", "Indentation probe"}} { + pressTreeProbeKey(t, probe.key) + for { + select { + case <-ctx.Done(): + t.Fatal("missing speech metadata focus event", probe.name) + case event := <-adapter.Events(): + if !((event.Type == FocusEvent || event.Type == FocusedStateEvent) && event.Source.Name == probe.name) { + continue + } + windowContext := false + for _, ancestor := range event.Source.BrailleContext { + if ancestor.Role == "frame" && ancestor.Name != "" { + windowContext = true + } + } + if !windowContext { + t.Fatalf("missing native braille window context: %+v", event.Source.BrailleContext) + } + if probe.key == "F9" { + if !strings.Contains(strings.ToLower(event.Source.Mnemonic), "a") { + t.Fatalf("missing native mnemonic: %q", event.Source.Mnemonic) + } + } else { + if event.Source.BrailleLine != "\t Indented É" { + t.Fatalf("raw indentation lost: %q", event.Source.BrailleLine) + } + found := false + for _, ancestor := range event.Source.SpeechContext { + if (ancestor.Role == "list" || ancestor.Role == "list box") && ancestor.Name == "Dynamic controls" { + if !ancestor.ChildCountKnown || ancestor.ChildCount != 10 { + t.Fatalf("native list count unavailable: %+v", ancestor) + } + found = true + } + } + if !found { + t.Fatalf("missing native list context: %+v", event.Source.SpeechContext) + } + } + goto nextProbe + } + } + nextProbe: + } + pressTreeProbeKey(t, "F11") + for { + select { + case <-ctx.Done(): + t.Fatal("missing color attribute focus event") + case event := <-adapter.Events(): + if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.Name != "Color attribute probe" { + continue + } + if len(event.Source.TextAttributeRuns) == 0 || event.Source.TextAttributeRuns[0].Values["fg-color"] == "" { + t.Fatalf("native braille attribute runs missing: %+v", event.Source.TextAttributeRuns) + } + if !event.Source.Misspelled || !TextAttributesMisspelled(event.Source.TextAttributeRuns[0].Values) { + t.Fatalf("native error underline lost its spelling indicator: %+v", event.Source.TextAttributeRuns) + } + attributes, err := adapter.ReadCharacterAttributes(ctx, event.Source.ID()) + if err != nil { + t.Fatal(err) + } + if !TextAttributesMisspelled(attributes.Values) { + t.Fatalf("character attributes lost spelling indicator: %+v", attributes.Values) + } + if color := attributes.Values["fg-color"]; color == "" { + t.Fatalf("native foreground color missing: %+v", attributes.Values) + } else { + t.Logf("native foreground color: %q", color) + } + goto protectedProbe + } + } +protectedProbe: + pressTreeProbeKey(t, "F7") + for { + select { + case <-ctx.Done(): + t.Fatal("missing protected text focus event") + case event := <-adapter.Events(): + if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.Name != "Protected text probe" { + continue + } + attributes, err := adapter.ReadCharacterAttributes(ctx, event.Source.ID()) + if err != nil || len(attributes.Values) != 0 { + t.Fatalf("password attributes: %+v, %v", attributes.Values, err) + } + return + } + } +} diff --git a/internal/atspi/tentacle_item_native.go b/internal/atspi/tentacle_item_native.go new file mode 100644 index 0000000..b759f60 --- /dev/null +++ b/internal/atspi/tentacle_item_native.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package atspi + +/* +#include "adapter_native.h" +static char *tentacle_attribute(GHashTable *attributes, const char *key) { + return attributes ? g_hash_table_lookup(attributes, key) : NULL; +} +*/ +import "C" +import ( + "context" + "errors" + "strings" + "unsafe" +) + +type TransferItem struct{ Text, Path string } + +// ReadTransferItem reads one current line or one explicit path from the +// foreground object. It never walks descendants or reads password contents. +func (adapter *Adapter) ReadTransferItem(ctx context.Context, object ObjectID, offset int) (TransferItem, error) { + result, err := adapter.documentOperation(ctx, treeCaptureRequest{transferItem: true, reviewFocus: object, positionOffset: offset}) + return result.item, err +} +func (adapter *Adapter) nativeTransferItem(ctx context.Context, object ObjectID, offset int) (TransferItem, error) { + source, _, err := adapter.native_action_source(object) + if err != nil || ctx.Err() != nil { + return TransferItem{}, ErrStaleObject + } + if C.atspi_accessible_get_role(source, nil) == C.ATSPI_ROLE_PASSWORD_TEXT { + return TransferItem{}, errors.New("protected item") + } + attributes := C.atspi_accessible_get_attributes(source, nil) + if attributes != nil { + defer C.g_hash_table_unref(attributes) + for _, key := range []string{"file-uri", "uri", "file-path"} { + rawKey := C.CString(key) + raw := C.tentacle_attribute(attributes, rawKey) + C.free(unsafe.Pointer(rawKey)) + if raw != nil { + path := C.GoString(raw) + if strings.HasPrefix(path, "file:") || strings.HasPrefix(path, "/") { + return TransferItem{Path: path}, nil + } + } + } + } + snapshot := snapshotFromNativeWithCaret(source, false, false, false, false) + // File icons and rows without explicit paths must not become text messages. + if snapshot.Role == "icon" || snapshot.Role == "list item" || snapshot.Role == "table cell" && !snapshot.Spreadsheet { + return TransferItem{}, errors.New("file item does not expose a reliable path") + } + value := snapshot.Name + text := C.atspi_accessible_get_text_iface(source) + if text != nil { + defer C.g_object_unref(C.gpointer(text)) + if offset < 0 { + offset = int(C.atspi_text_get_caret_offset(text, nil)) + if offset < 0 { + offset = 0 + } + } + line := C.atspi_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil) + if line == nil { + return TransferItem{}, errors.New("focused line unavailable") + } + value = C.GoString(line.content) + C.g_free(C.gpointer(line.content)) + C.g_free(C.gpointer(line)) + // Native line delimiters are not part of the message; retain actual spacing. + value = strings.TrimRight(value, "\r\n") + } + if len(value) >= 4096 { + return TransferItem{}, errors.New("focused item exceeds transfer limit") + } + if value == "" { + return TransferItem{}, errors.New("focused item has no text or path") + } + return TransferItem{Text: strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(value)}, nil +} diff --git a/internal/atspi/text.go b/internal/atspi/text.go new file mode 100644 index 0000000..f1865e1 --- /dev/null +++ b/internal/atspi/text.go @@ -0,0 +1,324 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" + "strings" + "unicode/utf8" +) + +const ( + // A large terminal viewport can contain thousands of padded cells. + // Keep the per-line query and web line-repair budgets independently small. + maxReviewCharacters = 1 << 16 + maxReviewLineCharacters = 4096 + maxReviewRanges = 256 + maxTreeTextBytes = 1 << 20 +) + +var ErrStaleText = errors.New("accessible text requires recapture") + +// TextRange owns exact UTF-8 text and AT-SPI character offsets [Start, End). +// Bounds are clipped screen coordinates. Offsets count Unicode code points, +// not bytes or grapheme clusters. Embedded objects have their own tree nodes. +type TextRange struct { + Start, End int + Text string + Bounds Rectangle +} + +// VisibleText distinguishes a complete capture from unavailable or oversized +// text. A limited capture contains no partial ranges. Known empty text is valid. +// Visibility describes component clipping, not occlusion by other windows. +type VisibleText struct { + Known bool + Limited bool + Ranges []TextRange + CaretKnown bool + CaretOffset int +} + +func (text VisibleText) valid(geometry Geometry) bool { + if text.CaretKnown && (!text.Known || text.CaretOffset < 0) || !text.CaretKnown && text.CaretOffset != 0 { + return false + } + if text.Known && text.Limited || !text.Known && len(text.Ranges) != 0 || len(text.Ranges) > maxReviewRanges { + return false + } + if text.Known && !geometry.BoundsKnown { + return false + } + previous, characters := 0, 0 + for _, r := range text.Ranges { + // Native offsets can include arbitrarily long offscreen scrollback. + // Bound the captured characters, not their absolute source offsets. + if r.Start < previous || r.End <= r.Start || !utf8.ValidString(r.Text) || utf8.RuneCountInString(r.Text) != r.End-r.Start || strings.ContainsRune(r.Text, '\ufffc') { + return false + } + // A newline-only range represents a blank visual row. Mixed text + // and line breaks must still be split by the capture provider. + if strings.ContainsAny(r.Text, "\n\r") && strings.Trim(r.Text, "\n\r") != "" { + return false + } + clipped, ok := r.Bounds.Intersection(geometry.Bounds) + if !ok || clipped != r.Bounds { + return false + } + previous = r.End + characters += r.End - r.Start + } + return characters <= maxReviewCharacters +} + +func (tree *AccessibleTree) VisibleText(reference ObjectReference) (VisibleText, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(reference) + if err != nil { + return VisibleText{}, err + } + if entry.textInvalid || entry.geometryInvalid { + return VisibleText{}, ErrStaleText + } + text := entry.text + text.Ranges = append([]TextRange(nil), text.Ranges...) + return text, nil +} + +// InvalidateText preserves identity and geometry. Layout invalidation also +// invalidates text via geometryInvalid, including every moved descendant. +func (tree *AccessibleTree) InvalidateText(id ObjectID) int { + tree.mu.Lock() + defer tree.mu.Unlock() + entry, ok := tree.nodes[id] + if !ok || entry.textInvalid { + return 0 + } + entry.textInvalid = true + entry.text = VisibleText{} + tree.nodes[id] = entry + return 1 +} + +type reviewTextSource interface { + count(context.Context) (int, error) + line(context.Context, int) (TextRange, error) + bounds(context.Context, int, int) (Rectangle, error) +} + +// VTE can report UTF-8 bytes as its character count while its text and line +// methods use character offsets. Locate the actual end with bounded single- +// character queries, without copying or scanning terminal scrollback. +func terminalCharacterCount(ctx context.Context, reported int, exists func(context.Context, int) (bool, error)) (int, error) { + if reported <= 0 { + return reported, ctx.Err() + } + present, err := exists(ctx, reported-1) + if err != nil || present { + return reported, err + } + low, high := 0, reported-1 + for low < high { + if err := ctx.Err(); err != nil { + return 0, err + } + middle := low + (high-low)/2 + present, err := exists(ctx, middle) + if err != nil { + return 0, err + } + if present { + low = middle + 1 + } else { + high = middle + } + } + return low, ctx.Err() +} + +// captureVisibleText finds the visible portion of native text before walking +// its visual lines. This avoids starting at an offscreen terminal scrollback +// offset: some terminal providers return the first on-screen line when asked +// for offset zero, with a later reported start offset. +func captureVisibleText(ctx context.Context, source reviewTextSource, clip Rectangle) (VisibleText, error) { + unknown := VisibleText{} + if err := ctx.Err(); err != nil { + return unknown, err + } + count, err := source.count(ctx) + if err != nil { + return unknown, err + } + if count < 0 { + return unknown, errors.New("negative review character count") + } + start, end, err := visibleTextOffsets(ctx, source, count, clip) + if err != nil { + if count > maxReviewCharacters { + return VisibleText{Limited: true}, nil + } + // Some otherwise usable GTK text providers do not expose per-character + // extents. Retain the established bounded line capture for those small + // objects; large providers still need a viewport to avoid scrollback. + start, end = 0, count + } + if end-start > maxReviewCharacters { + return VisibleText{Limited: true}, nil + } + result := VisibleText{Known: true} + for offset, lines := start, 0; offset < end; lines++ { + if err := ctx.Err(); err != nil { + return unknown, err + } + if lines >= maxReviewRanges { + return VisibleText{Limited: true}, nil + } + line, err := source.line(ctx, offset) + if err != nil { + return unknown, err + } + // Some AT-SPI providers return a line whose reported start is just + // after the requested offset. Python Cthulhu retries one character + // earlier for that provider quirk before rejecting the capture. + if line.Start > offset && offset > 0 { + line, err = source.line(ctx, offset-1) + if err != nil { + return unknown, err + } + } + if line.Start < 0 || line.Start > offset || line.End <= offset || line.End > count || len(line.Text) > maxReviewLineCharacters*utf8.UTFMax || !utf8.ValidString(line.Text) || utf8.RuneCountInString(line.Text) != line.End-line.Start { + return unknown, errors.New("invalid review line offsets or content") + } + lineStart := max(line.Start, offset) + lineEnd := min(line.End, end) + runes := []rune(line.Text)[lineStart-line.Start : lineEnd-line.Start] + // Python retains newline-only TextZones. Keep their exact offsets and + // native bounds so empty terminal rows remain navigable and speak blank. + blankLine := len(runes) > 0 && strings.Trim(string(runes), "\n\r") == "" + for start := 0; start < len(runes); { + if !blankLine && (runes[start] == '\n' || runes[start] == '\r') || runes[start] == '\ufffc' { + start++ + continue + } + end := start + 1 + if blankLine { + // Some terminal providers combine consecutive empty visual rows + // into one newline-only line range. Preserve each row as its own + // range so line review can advance through all of them. Treat CRLF + // as one row rather than two. + if runes[start] == '\r' && end < len(runes) && runes[end] == '\n' { + end++ + } + } else { + for end < len(runes) && runes[end] != '\n' && runes[end] != '\r' && runes[end] != '\ufffc' { + end++ + } + } + bounds, err := source.bounds(ctx, lineStart+start, lineStart+end) + if err != nil { + return unknown, err + } + clipped, visible := bounds.Intersection(clip) + if visible && clipped == bounds { + result.Ranges = append(result.Ranges, TextRange{lineStart + start, lineStart + end, string(runes[start:end]), bounds}) + } else if visible { + // A clipped rectangle alone cannot hide offscreen characters. Check each + // character only for partially clipped runs, preserving disjoint spans. + for i := start; i < end; i++ { + if err := ctx.Err(); err != nil { + return unknown, err + } + charBounds, err := source.bounds(ctx, lineStart+i, lineStart+i+1) + if err != nil { + return unknown, err + } + if charClip, ok := charBounds.Intersection(clip); ok { + result.Ranges = append(result.Ranges, TextRange{lineStart + i, lineStart + i + 1, string(runes[i]), charClip}) + } + if len(result.Ranges) > maxReviewRanges { + return VisibleText{Limited: true}, nil + } + } + } + if len(result.Ranges) > maxReviewRanges { + return VisibleText{Limited: true}, nil + } + start = end + } + offset = max(line.End, offset+1) + } + return result, ctx.Err() +} + +// visibleTextOffsets uses character extents only to locate the native viewport; +// the returned text remains line-based. AT-SPI text offsets are ordered by +// visual rows for the providers supported by flat review, allowing bounded +// binary searches instead of a scan through terminal scrollback. +func visibleTextOffsets(ctx context.Context, source reviewTextSource, count int, clip Rectangle) (int, int, error) { + if count == 0 { + return 0, 0, nil + } + if !clip.Valid() { + return 0, 0, errors.New("invalid review text clip") + } + bottom := int64(clip.Y) + int64(clip.Height) + first, err := searchTextOffset(ctx, source, count, func(bounds Rectangle) bool { + return int64(bounds.Y)+int64(bounds.Height) > int64(clip.Y) + }) + if err != nil { + return 0, 0, err + } + if first == count { + return count, count, nil + } + end, err := searchTextOffset(ctx, source, count, func(bounds Rectangle) bool { + return int64(bounds.Y) >= bottom + }) + if err != nil { + return 0, 0, err + } + if end < first { + return 0, 0, errors.New("invalid review text viewport order") + } + return first, end, nil +} + +func searchTextOffset(ctx context.Context, source reviewTextSource, count int, after func(Rectangle) bool) (int, error) { + low, high := 0, count + for low < high { + if err := ctx.Err(); err != nil { + return 0, err + } + middle := low + (high-low)/2 + bounds, err := source.bounds(ctx, middle, middle+1) + if err != nil { + return 0, err + } + if !bounds.Valid() { + return 0, errors.New("invalid review character bounds") + } + if after(bounds) { + high = middle + } else { + low = middle + 1 + } + } + return low, nil +} diff --git a/internal/atspi/text_attributes.go b/internal/atspi/text_attributes.go new file mode 100644 index 0000000..d2b0bfb --- /dev/null +++ b/internal/atspi/text_attributes.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +// TextAttributesMisspelled recognizes provider spelling markers for native +// snapshots, character-attribute speech, and braille. Python Cthulhu also +// recognizes the error/spelling underline values used by some toolkits. +func TextAttributesMisspelled(values map[string]string) bool { + for _, key := range []string{"invalid", "invalid-spelling", "text-spelling", "spelling"} { + value := values[key] + if value == "spelling" || key != "invalid" && (value == "true" || value == "misspelled") { + return true + } + } + return values["underline"] == "error" || values["underline"] == "spelling" +} diff --git a/internal/atspi/text_native.go b/internal/atspi/text_native.go new file mode 100644 index 0000000..58ee8f9 --- /dev/null +++ b/internal/atspi/text_native.go @@ -0,0 +1,248 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +/* +#cgo pkg-config: atspi-2 +#include +#include +// Bound the Go copy even when an application returns an oversized line. +static int review_text_length(const gchar *text, int limit) { + if (!text) return -1; + return (int)strnlen(text, limit + 1); +} +*/ +import "C" + +import ( + "context" + "errors" + "strings" + "time" + "unicode/utf8" +) + +// Expansion follows ScriptUtilities.expandEOCs for the captured line only. +// All native references stay on the GLib owner. Bound nested provider work +// and output separately; one native call is also bounded by libatspi's timeout. +type embeddedTextBudget struct { + nodes, characters int + deadline time.Time +} + +func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, depth int, budget *embeddedTextBudget) string { + var result strings.Builder + offset := start - 1 + for _, char := range content { + offset++ + if budget.characters <= 0 { + break + } + if char != '\ufffc' { + result.WriteRune(char) + budget.characters-- + continue + } + if depth >= 8 || budget.nodes <= 0 || time.Now().After(budget.deadline) { + continue + } + budget.nodes-- + result.WriteString(expandNativeEmbeddedChild(source, offset, depth+1, budget)) + } + return result.String() +} + +func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, budget *embeddedTextBudget) string { + hypertext := C.atspi_accessible_get_hypertext_iface(source) + if hypertext == nil { + return "" + } + defer C.g_object_unref(C.gpointer(hypertext)) + var nativeErr *C.GError + index := C.atspi_hypertext_get_link_index(hypertext, C.gint(offset), &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + return "" + } + if index < 0 { + return "" + } + link := C.atspi_hypertext_get_link(hypertext, index, nil) + if link == nil { + return "" + } + defer C.g_object_unref(C.gpointer(link)) + child := C.atspi_hyperlink_get_object(link, 0, nil) + if child == nil { + return "" + } + defer C.g_object_unref(C.gpointer(child)) + role := C.atspi_accessible_get_role(child, nil) + if role == C.ATSPI_ROLE_PASSWORD_TEXT { + return "" + } + text := C.atspi_accessible_get_text_iface(child) + if text == nil { + return "" + } + defer C.g_object_unref(C.gpointer(text)) + count := C.atspi_text_get_character_count(text, nil) + if count <= 0 || time.Now().After(budget.deadline) { + return "" + } + content := C.atspi_text_get_text(text, 0, min(count, C.gint(maxSnapshotFieldLength)), nil) + if content == nil { + return "" + } + defer C.g_free(C.gpointer(content)) + length := int(C.review_text_length(content, C.int(maxSnapshotFieldLength*utf8.UTFMax))) + if length < 0 || length > maxSnapshotFieldLength*utf8.UTFMax { + return "" + } + expanded := expandNativeEmbeddedText(child, C.GoStringN(content, C.int(length)), 0, depth, budget) + switch role { + case C.ATSPI_ROLE_HEADING, C.ATSPI_ROLE_LIST, C.ATSPI_ROLE_LIST_ITEM, C.ATSPI_ROLE_PARAGRAPH, + C.ATSPI_ROLE_SECTION, C.ATSPI_ROLE_TABLE, C.ATSPI_ROLE_TABLE_CELL, C.ATSPI_ROLE_TABLE_ROW: + if budget.characters > 0 { + expanded += " " + budget.characters-- + } + } + return expanded +} + +type nativeReviewText struct { + text *C.AtspiText + terminal bool +} + +func (source nativeReviewText) count(ctx context.Context) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + var nativeErr *C.GError + count := C.atspi_text_get_character_count(source.text, &nativeErr) + if nativeErr != nil { + C.g_error_free(nativeErr) + return 0, errors.New("query review character count") + } + if source.terminal && count > 0 { + return terminalCharacterCount(ctx, int(count), source.characterExists) + } + return int(count), ctx.Err() +} + +func (source nativeReviewText) characterExists(ctx context.Context, offset int) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + var nativeErr *C.GError + text := C.atspi_text_get_text(source.text, C.gint(offset), C.gint(offset+1), &nativeErr) + if text != nil { + defer C.g_free(C.gpointer(text)) + } + if nativeErr != nil { + C.g_error_free(nativeErr) + return false, errors.New("query terminal review character") + } + length := int(C.review_text_length(text, C.int(utf8.UTFMax))) + if length < 0 || length > utf8.UTFMax { + return false, errors.New("invalid terminal review character") + } + value := C.GoStringN(text, C.int(length)) + if !utf8.ValidString(value) || utf8.RuneCountInString(value) > 1 { + return false, errors.New("invalid terminal review character") + } + return length != 0, ctx.Err() +} + +func (source nativeReviewText) line(ctx context.Context, offset int) (TextRange, error) { + if err := ctx.Err(); err != nil { + return TextRange{}, err + } + var nativeErr *C.GError + line := C.atspi_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr) + if line != nil { + defer C.g_boxed_free(C.atspi_text_range_get_type(), C.gpointer(line)) + } + if nativeErr != nil { + C.g_error_free(nativeErr) + return TextRange{}, errors.New("query review line") + } + if line == nil { + return TextRange{}, errors.New("missing review line") + } + length := int(C.review_text_length(line.content, C.int(maxReviewLineCharacters*utf8.UTFMax))) + if length < 0 || length > maxReviewLineCharacters*utf8.UTFMax { + return TextRange{}, errors.New("oversized or missing review line content") + } + return TextRange{Start: int(line.start_offset), End: int(line.end_offset), Text: C.GoStringN(line.content, C.int(length))}, ctx.Err() +} + +func (source nativeReviewText) bounds(ctx context.Context, start, end int) (Rectangle, error) { + if err := ctx.Err(); err != nil { + return Rectangle{}, err + } + var nativeErr *C.GError + bounds := C.atspi_text_get_range_extents(source.text, C.gint(start), C.gint(end), C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) + if bounds != nil { + defer C.g_free(C.gpointer(bounds)) + } + if nativeErr != nil { + C.g_error_free(nativeErr) + return Rectangle{}, errors.New("query review range bounds") + } + if bounds == nil { + return Rectangle{}, errors.New("missing review range bounds") + } + rect := Rectangle{int32(bounds.x), int32(bounds.y), int32(bounds.width), int32(bounds.height)} + if !rect.Valid() { + return Rectangle{}, errors.New("invalid review range bounds") + } + return rect, ctx.Err() +} + +// Only called from captureNativeTree on the adapter's GLib thread. Unsupported, +// hidden, protected, and failed text queries remain unavailable to review. +func textFromNative(ctx context.Context, accessible *C.AtspiAccessible, geometry Geometry, role string, clip Rectangle) (VisibleText, error) { + if err := ctx.Err(); err != nil { + return VisibleText{}, err + } + if !geometry.BoundsKnown || !clip.Valid() || role == "password text" { + return VisibleText{}, nil + } + text := C.atspi_accessible_get_text_iface(accessible) + if text == nil { + return VisibleText{}, ctx.Err() + } + defer C.g_object_unref(C.gpointer(text)) + result, err := captureVisibleText(ctx, nativeReviewText{text: text, terminal: role == "terminal"}, clip) + if err != nil { + return VisibleText{}, ctx.Err() + } + if result.Known && ctx.Err() == nil { + var nativeErr *C.GError + offset := int(C.atspi_text_get_caret_offset(text, &nativeErr)) + if nativeErr != nil { + C.g_error_free(nativeErr) + } else if offset >= 0 { + result.CaretKnown, result.CaretOffset = true, offset + } + } + return result, ctx.Err() +} diff --git a/internal/atspi/text_test.go b/internal/atspi/text_test.go new file mode 100644 index 0000000..9939f09 --- /dev/null +++ b/internal/atspi/text_test.go @@ -0,0 +1,386 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "context" + "errors" + "reflect" + "strings" + "sync" + "testing" +) + +type fakeReviewText struct { + characters int + lines map[int]TextRange + rectangles map[[2]int]Rectangle + failure error + cancel context.CancelFunc +} + +func (s fakeReviewText) count(ctx context.Context) (int, error) { return s.characters, s.failure } +func (s fakeReviewText) line(ctx context.Context, offset int) (TextRange, error) { + if s.cancel != nil { + s.cancel() + } + return s.lines[offset], s.failure +} +func (s fakeReviewText) bounds(ctx context.Context, start, end int) (Rectangle, error) { + if bounds, ok := s.rectangles[[2]int{start, end}]; ok { + return bounds, ctx.Err() + } + for span, bounds := range s.rectangles { + if span[0] <= start && end <= span[1] { + return bounds, ctx.Err() + } + } + return Rectangle{}, ctx.Err() +} + +func TestCaptureVisibleTextUnicodeClippingAndEmbeddedObjects(t *testing.T) { + clip := Rectangle{-10, 0, 20, 20} + source := fakeReviewText{characters: 8, lines: map[int]TextRange{ + 0: {Start: 0, End: 4, Text: "é猫x\n"}, 4: {Start: 4, End: 8, Text: "a\ufffcbz"}, + }, rectangles: map[[2]int]Rectangle{ + {0, 3}: {-20, 0, 30, 10}, {0, 1}: {-20, 0, 10, 10}, {1, 2}: {-10, 0, 10, 10}, {2, 3}: {0, 0, 10, 10}, + {4, 5}: {-10, 10, 5, 10}, {6, 8}: {-5, 10, 10, 10}, + }} + got, err := captureVisibleText(context.Background(), source, clip) + want := VisibleText{Known: true, Ranges: []TextRange{ + {1, 2, "猫", Rectangle{-10, 0, 10, 10}}, {2, 3, "x", Rectangle{0, 0, 10, 10}}, + {4, 5, "a", Rectangle{-10, 10, 5, 10}}, {6, 8, "bz", Rectangle{-5, 10, 10, 10}}, + }} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("capture = %#v %v, want %#v", got, err, want) + } +} + +func TestCaptureVisibleTextRejectsBadProgressAndCancellation(t *testing.T) { + for _, line := range []TextRange{ + {Start: 0, End: 0, Text: ""}, {Start: 1, End: 2, Text: "x"}, + {Start: 0, End: 3, Text: "abc"}, {Start: 0, End: 2, Text: "é"}, {Start: 0, End: 2, Text: "\xffx"}, + } { + got, err := captureVisibleText(context.Background(), fakeReviewText{characters: 2, lines: map[int]TextRange{0: line}}, Rectangle{0, 0, 10, 10}) + if err == nil || got.Known || len(got.Ranges) != 0 { + t.Fatalf("bad line accepted: %#v => %#v %v", line, got, err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + source := fakeReviewText{characters: 1, lines: map[int]TextRange{0: {Start: 0, End: 1, Text: "x"}}, rectangles: map[[2]int]Rectangle{{0, 1}: {0, 0, 1, 1}}, cancel: cancel} + if _, err := captureVisibleText(ctx, source, Rectangle{0, 0, 10, 10}); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation = %v", err) + } +} + +func TestCaptureVisibleTextRetriesProviderLineStartAheadOfOffset(t *testing.T) { + source := fakeReviewText{characters: 5, lines: map[int]TextRange{ + 0: {Start: 0, End: 2, Text: "ab"}, + 1: {Start: 2, End: 5, Text: "cde"}, + 2: {Start: 3, End: 5, Text: "de"}, + }, rectangles: map[[2]int]Rectangle{ + {0, 2}: {0, 0, 2, 1}, {2, 5}: {0, 1, 3, 1}, + }} + got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 10}) + want := VisibleText{Known: true, Ranges: []TextRange{ + {0, 2, "ab", Rectangle{0, 0, 2, 1}}, {2, 5, "cde", Rectangle{0, 1, 3, 1}}, + }} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("capture = %#v %v, want %#v", got, err, want) + } +} + +func TestCaptureVisibleTextSeeksTerminalViewportBeforeReadingLines(t *testing.T) { + const count = 1000000 + source := fakeReviewText{characters: count, lines: map[int]TextRange{ + 420000: {Start: 420000, End: 420005, Text: "first"}, + 420005: {Start: 420005, End: 420011, Text: "second"}, + }, rectangles: map[[2]int]Rectangle{ + {0, 420000}: {0, -20, 1, 10}, + {420000, 420005}: {0, 0, 5, 10}, + {420005, 420011}: {0, 10, 6, 10}, + {420011, count}: {0, 20, 1, 10}, + }} + got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 100, 20}) + want := VisibleText{Known: true, Ranges: []TextRange{ + {420000, 420005, "first", Rectangle{0, 0, 5, 10}}, + {420005, 420011, "second", Rectangle{0, 10, 6, 10}}, + }} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("capture = %#v %v, want %#v", got, err, want) + } + got.CaretKnown, got.CaretOffset = true, 420010 + if !got.valid(Geometry{BoundsKnown: true, Bounds: Rectangle{0, 0, 100, 20}}) { + t.Fatal("visible terminal scrollback offsets rejected by tree validation") + } +} + +func TestCaptureVisibleTextLargeTerminalScreen(t *testing.T) { + const columns, rows = 160, 55 + source := fakeReviewText{characters: columns * rows, lines: map[int]TextRange{}, rectangles: map[[2]int]Rectangle{}} + for row := 0; row < rows; row++ { + start := row * columns + source.lines[start] = TextRange{Start: start, End: start + columns, Text: strings.Repeat("x", columns)} + source.rectangles[[2]int{start, start + columns}] = Rectangle{0, int32(row * 17), columns * 8, 17} + } + clip := Rectangle{0, 0, columns * 8, rows * 17} + got, err := captureVisibleText(context.Background(), source, clip) + if err != nil || !got.Known || got.Limited || len(got.Ranges) != rows { + t.Fatalf("large terminal capture: known=%v limited=%v ranges=%d err=%v", got.Known, got.Limited, len(got.Ranges), err) + } + got.CaretKnown, got.CaretOffset = true, source.characters-1 + if !got.valid(Geometry{BoundsKnown: true, Bounds: clip}) { + t.Fatal("large terminal capture rejected by tree validation") + } +} + +func TestCaptureVisibleTextLimitsAndEmpty(t *testing.T) { + for _, count := range []int{0, maxReviewCharacters + 1, -1} { + got, err := captureVisibleText(context.Background(), fakeReviewText{characters: count}, Rectangle{0, 0, 10, 10}) + if count == 0 && (err != nil || !got.Known || len(got.Ranges) != 0) { + t.Fatalf("empty = %#v %v", got, err) + } + if count > maxReviewCharacters && (err != nil || !got.Limited || got.Known || len(got.Ranges) != 0) { + t.Fatalf("limit = %#v %v", got, err) + } + if count < 0 && err == nil { + t.Fatal("negative count accepted") + } + } + count := maxReviewCharacters + 1 + oversized := fakeReviewText{characters: count, rectangles: map[[2]int]Rectangle{{0, count}: {0, 0, 10, 10}}} + if got, err := captureVisibleText(context.Background(), oversized, Rectangle{0, 0, 10, 10}); err != nil || !got.Limited || got.Known || len(got.Ranges) != 0 { + t.Fatalf("oversized visible viewport: %#v %v", got, err) + } + source := fakeReviewText{characters: maxReviewRanges + 1, lines: map[int]TextRange{}, rectangles: map[[2]int]Rectangle{}} + for i := 0; i < source.characters; i++ { + source.lines[i] = TextRange{Start: i, End: i + 1, Text: "x"} + source.rectangles[[2]int{i, i + 1}] = Rectangle{0, 0, 1, 1} + } + got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 10}) + if err != nil || !got.Limited || len(got.Ranges) != 0 { + t.Fatalf("range limit = %#v %v", got, err) + } +} + +func textNode() TreeNode { + node := testNode("/root", "Text") + node.Geometry = Geometry{Bounds: Rectangle{-10, 0, 20, 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + node.Text = VisibleText{Known: true, Ranges: []TextRange{{0, 2, "é猫", Rectangle{-10, 0, 20, 10}}}} + return node +} + +func TestTreeTextOwnershipInvalidationAndReplacement(t *testing.T) { + node := textNode() + tree, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node}) + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Root() + node.Text.Ranges[0].Text = "xx" + got, _ := tree.VisibleText(ref) + if got.Ranges[0].Text != "é猫" { + t.Fatal("tree retained caller slice") + } + got.Ranges[0].Text = "yy" + got, _ = tree.VisibleText(ref) + if got.Ranges[0].Text != "é猫" { + t.Fatal("tree exposed owned slice") + } + if n := tree.InvalidateText(ref.ID()); n != 1 { + t.Fatalf("invalidated %d", n) + } + if n := tree.InvalidateText(ref.ID()); n != 0 { + t.Fatalf("repeat invalidated %d", n) + } + if _, err := tree.VisibleText(ref); !errors.Is(err, ErrStaleText) { + t.Fatalf("stale = %v", err) + } + if _, err := tree.Geometry(ref); err != nil { + t.Fatal(err) + } + if _, err := tree.Resolve(ref); err != nil { + t.Fatal(err) + } + if err := tree.Replace(node.Snapshot.ID(), []TreeNode{node}); err != nil { + t.Fatal(err) + } + if got, err := tree.VisibleText(ref); err != nil || got.Ranges[0].Text != "xx" { + t.Fatalf("replace = %#v %v", got, err) + } + tree.InvalidateGeometry(ref.ID()) + if _, err := tree.VisibleText(ref); !errors.Is(err, ErrStaleText) { + t.Fatalf("layout = %v", err) + } + tree.Invalidate(ref.ID()) + if _, err := tree.VisibleText(ref); !errors.Is(err, ErrStaleObject) { + t.Fatalf("removed = %v", err) + } +} + +func TestTreeRejectsMalformedText(t *testing.T) { + for _, text := range []VisibleText{ + {Ranges: []TextRange{{0, 1, "x", Rectangle{0, 0, 1, 1}}}}, + {Known: true, Limited: true}, + {Known: true, Ranges: []TextRange{{0, 2, "é", Rectangle{0, 0, 1, 1}}}}, + {Known: true, Ranges: []TextRange{{0, 1, "x", Rectangle{10, 0, 1, 1}}}}, + {Known: true, Ranges: []TextRange{{0, 1, "\ufffc", Rectangle{0, 0, 1, 1}}}}, + {Known: true, Ranges: []TextRange{{0, 3, "a\nb", Rectangle{0, 0, 1, 1}}}}, + {Known: true, Ranges: []TextRange{{0, 1, "x", Rectangle{0, 0, 1, 1}}, {0, 1, "x", Rectangle{0, 0, 1, 1}}}}, + } { + node := textNode() + node.Text = text + if _, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node}); !errors.Is(err, ErrInvalidTree) { + t.Fatalf("accepted %#v: %v", text, err) + } + } + node := textNode() + node.Geometry = Geometry{} + if _, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node}); !errors.Is(err, ErrInvalidTree) { + t.Fatalf("text without geometry accepted: %v", err) + } +} + +func TestTreeTextConcurrentInvalidation(t *testing.T) { + node := textNode() + tree, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node}) + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Root() + var readers sync.WaitGroup + for range 8 { + readers.Add(1) + go func() { + defer readers.Done() + for range 100 { + _, err := tree.VisibleText(ref) + if err != nil && !errors.Is(err, ErrStaleText) { + t.Errorf("read: %v", err) + } + } + }() + } + tree.InvalidateText(ref.ID()) + readers.Wait() +} + +func TestAdapterTextInvalidationPolicy(t *testing.T) { + for _, event := range []string{TextInsertedEvent, TextDeletedEvent + ":system", TextBoundsChangedEvent, TextAttributesChangedEvent, CaretEvent} { + tree := testTree(t) + adapter := New() + adapter.tree = tree + first, _ := tree.Reference(testID("/first")) + second, _ := tree.Reference(testID("/second")) + adapter.invalidateTreeEvent(event, 0, first.ID(), ObjectID{}) + if _, err := tree.VisibleText(first); !errors.Is(err, ErrStaleText) { + t.Fatalf("%s: %v", event, err) + } + if _, err := tree.VisibleText(second); err != nil { + t.Fatalf("unrelated text: %v", err) + } + if _, err := tree.Resolve(first); err != nil { + t.Fatal(err) + } + } +} + +func TestTreeTextTotalBudget(t *testing.T) { + root := testNode("/root", "Window") + nodes := []TreeNode{root} + for i := 0; i <= maxTreeTextBytes/(maxReviewCharacters*3); i++ { + node := textNode() + node.Snapshot.Path = "/n" + strings.Repeat("x", i+1) + node.Text.Ranges[0] = TextRange{0, maxReviewCharacters, strings.Repeat("猫", maxReviewCharacters), Rectangle{0, 0, 1, 1}} + nodes = append(nodes, node) + nodes[0].Children = append(nodes[0].Children, node.Snapshot.ID()) + } + if _, err := NewAccessibleTree(root.Snapshot.ID(), nodes); !errors.Is(err, ErrInvalidTree) { + t.Fatalf("total budget: %v", err) + } +} + +func TestTerminalCharacterCount(t *testing.T) { + for _, tc := range []struct{ reported, actual int }{{0, 0}, {49, 48}, {30, 10}, {10, 10}, {1000000, 700000}, {8, 0}} { + calls := 0 + got, err := terminalCharacterCount(context.Background(), tc.reported, func(ctx context.Context, offset int) (bool, error) { + calls++ + if offset < 0 || offset >= tc.reported { + t.Fatalf("offset out of bounds: %d", offset) + } + return offset < tc.actual, nil + }) + if err != nil || got != tc.actual || calls > 22 { + t.Fatalf("%+v: count=%d calls=%d err=%v", tc, got, calls, err) + } + } + failure := errors.New("provider unavailable") + if _, err := terminalCharacterCount(context.Background(), 10, func(context.Context, int) (bool, error) { return false, failure }); !errors.Is(err, failure) { + t.Fatalf("failure = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := terminalCharacterCount(ctx, 10, func(ctx context.Context, _ int) (bool, error) { return false, ctx.Err() }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation = %v", err) + } +} + +func TestCaptureVisibleTextRetainsBlankRows(t *testing.T) { + source := fakeReviewText{characters: 6, lines: map[int]TextRange{ + 0: {Start: 0, End: 2, Text: "a\n"}, + 2: {Start: 2, End: 3, Text: "\n"}, + 3: {Start: 3, End: 5, Text: "b\n"}, + 5: {Start: 5, End: 6, Text: "\n"}, + }, rectangles: map[[2]int]Rectangle{ + {0, 2}: {0, 0, 10, 10}, {2, 3}: {0, 10, 10, 10}, + {3, 5}: {0, 20, 10, 10}, {5, 6}: {0, 30, 10, 10}, + }} + clip := Rectangle{0, 0, 10, 40} + got, err := captureVisibleText(context.Background(), source, clip) + want := VisibleText{Known: true, Ranges: []TextRange{ + {0, 1, "a", Rectangle{0, 0, 10, 10}}, + {2, 3, "\n", Rectangle{0, 10, 10, 10}}, + {3, 4, "b", Rectangle{0, 20, 10, 10}}, + {5, 6, "\n", Rectangle{0, 30, 10, 10}}, + }} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("capture=%+v err=%v want=%+v", got, err, want) + } + if !got.valid(Geometry{BoundsKnown: true, Bounds: clip}) { + t.Fatal("blank row capture rejected by tree validation") + } +} + +func TestCaptureVisibleTextSplitsCombinedBlankRows(t *testing.T) { + source := fakeReviewText{characters: 4, lines: map[int]TextRange{ + 0: {Start: 0, End: 4, Text: "\n\r\n\n"}, + }, rectangles: map[[2]int]Rectangle{ + {0, 1}: {0, 0, 10, 10}, + {1, 3}: {0, 10, 10, 10}, + {3, 4}: {0, 20, 10, 10}, + }} + got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 30}) + want := VisibleText{Known: true, Ranges: []TextRange{ + {0, 1, "\n", Rectangle{0, 0, 10, 10}}, + {1, 3, "\r\n", Rectangle{0, 10, 10, 10}}, + {3, 4, "\n", Rectangle{0, 20, 10, 10}}, + }} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("capture = %#v %v, want %#v", got, err, want) + } +} diff --git a/internal/atspi/tree.go b/internal/atspi/tree.go new file mode 100644 index 0000000..892f577 --- /dev/null +++ b/internal/atspi/tree.go @@ -0,0 +1,554 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "errors" + "fmt" + "sync" +) + +// Large browser documents can expose more than ten thousand accessible nodes. +// Capture still has a time limit and a finite node budget on the native thread. +const maxTreeNodes = 30000 + +var ( + ErrStaleGeometry = errors.New("accessible geometry requires recapture") + ErrIncompleteTree = errors.New("accessible children require recapture") + ErrInvalidTree = errors.New("invalid accessible tree") + ErrDeadObject = errors.New("defunct accessible object") + ErrObjectNotFound = errors.New("accessible object not found") + ErrStaleObject = errors.New("stale accessible object") + ErrStaleSnapshot = errors.New("accessible semantics require recapture") +) + +// TreeNode is an owned, immutable accessible snapshot and the identities of +// its children in application-provided order. NewAccessibleTree and Replace +// copy Children before retaining a node. +type TreeNode struct { + Geometry Geometry + Text VisibleText + Snapshot Snapshot + Children []ObjectID +} + +// ObjectReference is a generation-checked reference to an owned snapshot. A +// reference becomes stale when its object or an ancestor is invalidated, or +// when a replacement tree no longer contains that object. +type ObjectReference struct { + id ObjectID + generation uint64 +} + +func (reference ObjectReference) ID() ObjectID { + return reference.id +} + +func (reference ObjectReference) Valid() bool { + return reference.id.Valid() && reference.generation != 0 +} + +type treeEntry struct { + snapshotInvalid bool + text VisibleText + textInvalid bool + geometry Geometry + geometryInvalid bool + childrenInvalid bool + snapshot Snapshot + children []ObjectID + parent ObjectID + hasParent bool + generation uint64 +} + +// AccessibleTree owns a validated accessibility tree. It contains no native +// references and supports concurrent readers while events replace or +// invalidate cached objects. +type AccessibleTree struct { + mu sync.RWMutex + root ObjectID + nodes map[ObjectID]treeEntry + nextGeneration uint64 +} + +func NewAccessibleTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) { + tree := &AccessibleTree{} + if err := tree.Replace(root, nodes); err != nil { + return nil, err + } + return tree, nil +} + +// Replace atomically installs a complete tree. References to identities that +// remain live retain their generation; removed identities become stale. If an +// invalidated identity later reappears, it receives a new generation. +func (tree *AccessibleTree) Replace(root ObjectID, nodes []TreeNode) error { + next, err := validateTree(root, nodes) + if err != nil { + return err + } + + tree.mu.Lock() + defer tree.mu.Unlock() + for id, entry := range next { + if current, ok := tree.nodes[id]; ok { + entry.generation = current.generation + } else { + tree.nextGeneration++ + if tree.nextGeneration == 0 { + // Reaching this would require creating more than 2^64 distinct + // object generations in one process. Keep zero reserved for an + // invalid reference if it nevertheless occurs. + tree.nextGeneration++ + } + entry.generation = tree.nextGeneration + } + next[id] = entry + } + tree.root = root + tree.nodes = next + return nil +} + +func (tree *AccessibleTree) Root() (ObjectReference, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, ok := tree.nodes[tree.root] + if !ok { + return ObjectReference{}, ErrObjectNotFound + } + return newObjectReference(tree.root, entry), nil +} + +func (tree *AccessibleTree) Reference(id ObjectID) (ObjectReference, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, ok := tree.nodes[id] + if !ok { + return ObjectReference{}, fmt.Errorf("%w: bus %q path %q", ErrObjectNotFound, id.BusName, id.Path) + } + return newObjectReference(id, entry), nil +} + +func (tree *AccessibleTree) Resolve(reference ObjectReference) (Snapshot, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(reference) + if err != nil { + return Snapshot{}, err + } + return entry.snapshot, nil +} + +// InvalidateSnapshot refreshes presentation and matching data without retiring +// a live identity. A renamed button is still the same browse-caret position. +func (tree *AccessibleTree) InvalidateSnapshot(id ObjectID) int { + tree.mu.Lock() + defer tree.mu.Unlock() + entry, ok := tree.nodes[id] + if !ok || entry.snapshotInvalid { + return 0 + } + entry.snapshotInvalid = true + tree.nodes[id] = entry + return 1 +} + +// nativeRefresh copies a dirty tree for incremental native recapture. +// Unchanged nodes and child lists can be reused; dirty fields and newly added +// objects must be read from the provider. A missing or replaced root cannot +// inherit this cache. +func (tree *AccessibleTree) nativeRefresh(root ObjectID) map[ObjectID]treeEntry { + tree.mu.RLock() + defer tree.mu.RUnlock() + if tree.root != root { + return nil + } + if _, ok := tree.nodes[root]; !ok { + return nil + } + dirty := false + nodes := make(map[ObjectID]treeEntry, len(tree.nodes)) + for id, entry := range tree.nodes { + dirty = dirty || entry.snapshotInvalid || entry.childrenInvalid || entry.textInvalid || entry.geometryInvalid + entry.children = append([]ObjectID(nil), entry.children...) + entry.text.Ranges = append([]TextRange(nil), entry.text.Ranges...) + nodes[id] = entry + } + if !dirty { + return nil + } + return nodes +} + +// subtreeIDs returns the captured identities rooted at reference. It is used +// only by the native adapter to classify the bounded burst of caret/focus and +// defunct events produced by browser caret placement. No native references +// escape through this copy. +func (tree *AccessibleTree) subtreeIDs(reference ObjectReference) ([]ObjectID, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + if _, err := tree.resolveLocked(reference); err != nil { + return nil, err + } + result := make([]ObjectID, 0, 4) + pending := []ObjectID{reference.id} + for len(pending) != 0 { + last := len(pending) - 1 + id := pending[last] + pending = pending[:last] + entry, ok := tree.nodes[id] + if !ok { + return nil, ErrIncompleteTree + } + result = append(result, id) + pending = append(pending, entry.children...) + } + return result, nil +} + +// Geometry returns capture-time screen bounds, or ErrStaleGeometry after a +// layout/visibility event. Structural identity remains live across such events. +func (tree *AccessibleTree) Geometry(reference ObjectReference) (Geometry, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(reference) + if err != nil { + return Geometry{}, err + } + if entry.geometryInvalid { + return Geometry{}, ErrStaleGeometry + } + return entry.geometry, nil +} + +// InvalidateGeometry marks an object's subtree for recapture without changing +// focus snapshots, identity generations, or child/sibling traversal. Moving a +// container can move every descendant. Work is bounded by maxTreeNodes. +func (tree *AccessibleTree) InvalidateGeometry(id ObjectID) int { + tree.mu.Lock() + defer tree.mu.Unlock() + pending := []ObjectID{id} + invalidated := 0 + for len(pending) != 0 { + last := len(pending) - 1 + currentID := pending[last] + pending = pending[:last] + entry, ok := tree.nodes[currentID] + if !ok || entry.geometryInvalid { + // A marked ancestor already marked its entire subtree. Only a + // complete Replace can introduce fresh descendants. + continue + } + pending = append(pending, entry.children...) + invalidated++ + entry.geometryInvalid = true + tree.nodes[currentID] = entry + } + return invalidated +} + +func (tree *AccessibleTree) Parent(reference ObjectReference) (ObjectReference, bool, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(reference) + if err != nil { + return ObjectReference{}, false, err + } + if !entry.hasParent { + return ObjectReference{}, false, nil + } + parent := tree.nodes[entry.parent] + return newObjectReference(entry.parent, parent), true, nil +} + +func (tree *AccessibleTree) FirstChild(reference ObjectReference) (ObjectReference, bool, error) { + return tree.child(reference, false) +} + +func (tree *AccessibleTree) LastChild(reference ObjectReference) (ObjectReference, bool, error) { + return tree.child(reference, true) +} + +func (tree *AccessibleTree) child(reference ObjectReference, last bool) (ObjectReference, bool, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(reference) + if err != nil { + return ObjectReference{}, false, err + } + if entry.childrenInvalid { + return ObjectReference{}, false, ErrIncompleteTree + } + if len(entry.children) == 0 { + return ObjectReference{}, false, nil + } + index := 0 + if last { + index = len(entry.children) - 1 + } + id := entry.children[index] + return newObjectReference(id, tree.nodes[id]), true, nil +} + +func (tree *AccessibleTree) PreviousSibling(reference ObjectReference) (ObjectReference, bool, error) { + return tree.sibling(reference, -1) +} + +func (tree *AccessibleTree) NextSibling(reference ObjectReference) (ObjectReference, bool, error) { + return tree.sibling(reference, 1) +} + +func (tree *AccessibleTree) sibling(reference ObjectReference, direction int) (ObjectReference, bool, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + entry, err := tree.resolveLocked(reference) + if err != nil { + return ObjectReference{}, false, err + } + if !entry.hasParent { + return ObjectReference{}, false, nil + } + parent := tree.nodes[entry.parent] + for index, id := range parent.children { + if id != reference.id { + continue + } + siblingIndex := index + direction + if siblingIndex < 0 || siblingIndex >= len(parent.children) { + return ObjectReference{}, false, nil + } + siblingID := parent.children[siblingIndex] + return newObjectReference(siblingID, tree.nodes[siblingID]), true, nil + } + return ObjectReference{}, false, fmt.Errorf("%w: parent does not contain child", ErrInvalidTree) +} + +// Invalidate removes an object and all of its descendants. It returns the +// number of removed objects. An unknown identity is an idempotent no-op. +func (tree *AccessibleTree) Invalidate(id ObjectID) int { + tree.mu.Lock() + defer tree.mu.Unlock() + entry, ok := tree.nodes[id] + if !ok { + return 0 + } + if entry.hasParent { + parent := tree.nodes[entry.parent] + for index, childID := range parent.children { + if childID == id { + parent.children = append(parent.children[:index:index], parent.children[index+1:]...) + tree.nodes[entry.parent] = parent + break + } + } + } + + removed := 0 + pending := []ObjectID{id} + for len(pending) != 0 { + last := len(pending) - 1 + currentID := pending[last] + pending = pending[:last] + current, present := tree.nodes[currentID] + if !present { + continue + } + pending = append(pending, current.children...) + delete(tree.nodes, currentID) + removed++ + } + return removed +} + +// InvalidateChildren removes every descendant of a live parent while keeping +// the parent reference valid. Structural additions use this conservative path +// because the cached child order is no longer complete until the next capture. +// Child traversal returns ErrIncompleteTree until Replace installs a fresh tree; +// the parent snapshot and its own parent/sibling relationships remain usable. +func (tree *AccessibleTree) InvalidateChildren(parentID ObjectID) int { + tree.mu.Lock() + defer tree.mu.Unlock() + parent, ok := tree.nodes[parentID] + if !ok { + return 0 + } + pending := append([]ObjectID(nil), parent.children...) + parent.children = nil + parent.childrenInvalid = true + tree.nodes[parentID] = parent + removed := 0 + for len(pending) != 0 { + last := len(pending) - 1 + currentID := pending[last] + pending = pending[:last] + current, present := tree.nodes[currentID] + if !present { + continue + } + pending = append(pending, current.children...) + delete(tree.nodes, currentID) + removed++ + } + return removed +} + +// MarkChildrenChanged requires fresh child order without declaring the existing +// children dead. A document insertion does not replace its surviving siblings. +// Document traversal refuses the old order until Replace validates the capture; +// explicit removals and lifecycle events still retire their object generations. +func (tree *AccessibleTree) MarkChildrenChanged(parentID ObjectID) int { + tree.mu.Lock() + defer tree.mu.Unlock() + parent, ok := tree.nodes[parentID] + if !ok || parent.childrenInvalid { + return 0 + } + parent.childrenInvalid = true + tree.nodes[parentID] = parent + return 1 +} + +func (tree *AccessibleTree) resolveLocked(reference ObjectReference) (treeEntry, error) { + entry, ok := tree.nodes[reference.id] + if !ok { + if reference.Valid() { + return treeEntry{}, fmt.Errorf("%w: bus %q path %q", ErrStaleObject, reference.id.BusName, reference.id.Path) + } + return treeEntry{}, fmt.Errorf("%w: bus %q path %q", ErrObjectNotFound, reference.id.BusName, reference.id.Path) + } + if reference.generation == 0 || reference.generation != entry.generation { + return treeEntry{}, fmt.Errorf("%w: bus %q path %q", ErrStaleObject, reference.id.BusName, reference.id.Path) + } + if entry.snapshotInvalid { + return treeEntry{}, ErrStaleSnapshot + } + return entry, nil +} + +func newObjectReference(id ObjectID, entry treeEntry) ObjectReference { + return ObjectReference{id: id, generation: entry.generation} +} + +func validateTree(root ObjectID, nodes []TreeNode) (map[ObjectID]treeEntry, error) { + if !root.Valid() { + return nil, fmt.Errorf("%w: root identity is empty", ErrInvalidTree) + } + if len(nodes) == 0 { + return nil, fmt.Errorf("%w: tree is empty", ErrInvalidTree) + } + if len(nodes) > maxTreeNodes { + return nil, fmt.Errorf("%w: %d nodes exceeds limit %d", ErrInvalidTree, len(nodes), maxTreeNodes) + } + + entries := make(map[ObjectID]treeEntry, len(nodes)) + textBytes := 0 + for _, node := range nodes { + id := node.Snapshot.ID() + if !id.Valid() { + return nil, fmt.Errorf("%w: node identity is empty", ErrInvalidTree) + } + if _, exists := entries[id]; exists { + return nil, fmt.Errorf("%w: duplicate bus %q path %q", ErrInvalidTree, id.BusName, id.Path) + } + if node.Snapshot.Defunct { + return nil, fmt.Errorf("%w: %w at bus %q path %q", ErrInvalidTree, ErrDeadObject, id.BusName, id.Path) + } + if !node.Geometry.valid() { + return nil, fmt.Errorf("%w: invalid component geometry", ErrInvalidTree) + } + if !node.Text.valid(node.Geometry) { + return nil, fmt.Errorf("%w: invalid visible text", ErrInvalidTree) + } + for _, r := range node.Text.Ranges { + textBytes += len(r.Text) + } + if textBytes > maxTreeTextBytes { + return nil, fmt.Errorf("%w: visible text exceeds tree limit", ErrInvalidTree) + } + text := node.Text + text.Ranges = append([]TextRange(nil), text.Ranges...) + children := append([]ObjectID(nil), node.Children...) + entries[id] = treeEntry{snapshot: node.Snapshot, children: children, geometry: node.Geometry, text: text} + } + if _, ok := entries[root]; !ok { + return nil, fmt.Errorf("%w: root is missing", ErrInvalidTree) + } + + parents := make(map[ObjectID]ObjectID, len(nodes)-1) + for parentID, entry := range entries { + seenChildren := make(map[ObjectID]struct{}, len(entry.children)) + for _, childID := range entry.children { + if !childID.Valid() { + return nil, fmt.Errorf("%w: child identity is empty", ErrInvalidTree) + } + if _, exists := entries[childID]; !exists { + return nil, fmt.Errorf("%w: child bus %q path %q is missing", ErrInvalidTree, childID.BusName, childID.Path) + } + if _, duplicate := seenChildren[childID]; duplicate { + return nil, fmt.Errorf("%w: duplicate child bus %q path %q", ErrInvalidTree, childID.BusName, childID.Path) + } + seenChildren[childID] = struct{}{} + if _, exists := parents[childID]; exists { + return nil, fmt.Errorf("%w: child bus %q path %q has multiple parents", ErrInvalidTree, childID.BusName, childID.Path) + } + parents[childID] = parentID + } + } + if _, hasParent := parents[root]; hasParent { + return nil, fmt.Errorf("%w: root has a parent", ErrInvalidTree) + } + for id, entry := range entries { + parent, hasParent := parents[id] + if id != root && !hasParent { + return nil, fmt.Errorf("%w: bus %q path %q is disconnected", ErrInvalidTree, id.BusName, id.Path) + } + entry.parent = parent + entry.hasParent = hasParent + entries[id] = entry + } + + visited := make(map[ObjectID]bool, len(entries)) + active := make(map[ObjectID]bool, len(entries)) + var visit func(ObjectID) error + visit = func(id ObjectID) error { + if active[id] { + return fmt.Errorf("%w: cycle at bus %q path %q", ErrInvalidTree, id.BusName, id.Path) + } + if visited[id] { + return nil + } + active[id] = true + for _, childID := range entries[id].children { + if err := visit(childID); err != nil { + return err + } + } + active[id] = false + visited[id] = true + return nil + } + if err := visit(root); err != nil { + return nil, err + } + if len(visited) != len(entries) { + return nil, fmt.Errorf("%w: tree contains unreachable nodes", ErrInvalidTree) + } + return entries, nil +} diff --git a/internal/atspi/tree_test.go b/internal/atspi/tree_test.go new file mode 100644 index 0000000..a98ab77 --- /dev/null +++ b/internal/atspi/tree_test.go @@ -0,0 +1,408 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "errors" + "fmt" + "sync" + "testing" +) + +func testID(path string) ObjectID { + return ObjectID{BusName: ":1.42", Path: path} +} + +func testNode(path, name string, children ...ObjectID) TreeNode { + snapshot := NewSnapshot(path, name, "push button", "", "") + snapshot.BusName = ":1.42" + return TreeNode{Snapshot: snapshot, Children: children} +} + +func testTree(t *testing.T) *AccessibleTree { + t.Helper() + tree, err := NewAccessibleTree(testID("/root"), []TreeNode{ + testNode("/root", "Window", testID("/first"), testID("/second")), + testNode("/first", "First"), + testNode("/second", "Second", testID("/grandchild")), + testNode("/grandchild", "Grandchild"), + }) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestAccessibleTreeTraversal(t *testing.T) { + tree := testTree(t) + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + first, ok, err := tree.FirstChild(root) + if err != nil || !ok || first.ID() != testID("/first") { + t.Fatalf("first child = %#v, %v, %v", first, ok, err) + } + second, ok, err := tree.NextSibling(first) + if err != nil || !ok || second.ID() != testID("/second") { + t.Fatalf("next sibling = %#v, %v, %v", second, ok, err) + } + previous, ok, err := tree.PreviousSibling(second) + if err != nil || !ok || previous.ID() != first.ID() { + t.Fatalf("previous sibling = %#v, %v, %v", previous, ok, err) + } + last, ok, err := tree.LastChild(root) + if err != nil || !ok || last.ID() != second.ID() { + t.Fatalf("last child = %#v, %v, %v", last, ok, err) + } + parent, ok, err := tree.Parent(second) + if err != nil || !ok || parent.ID() != root.ID() { + t.Fatalf("parent = %#v, %v, %v", parent, ok, err) + } + grandchild, ok, err := tree.FirstChild(second) + if err != nil || !ok { + t.Fatalf("grandchild = %#v, %v, %v", grandchild, ok, err) + } + snapshot, err := tree.Resolve(grandchild) + if err != nil || snapshot.Name != "Grandchild" { + t.Fatalf("resolved snapshot = %#v, %v", snapshot, err) + } +} + +func TestAccessibleTreeTraversalBoundaries(t *testing.T) { + tree := testTree(t) + root, _ := tree.Root() + first, _, _ := tree.FirstChild(root) + second, _, _ := tree.LastChild(root) + grandchild, _, _ := tree.FirstChild(second) + + checks := []struct { + name string + call func() (ObjectReference, bool, error) + }{ + {name: "root parent", call: func() (ObjectReference, bool, error) { return tree.Parent(root) }}, + {name: "root sibling", call: func() (ObjectReference, bool, error) { return tree.NextSibling(root) }}, + {name: "first previous", call: func() (ObjectReference, bool, error) { return tree.PreviousSibling(first) }}, + {name: "last next", call: func() (ObjectReference, bool, error) { return tree.NextSibling(second) }}, + {name: "leaf child", call: func() (ObjectReference, bool, error) { return tree.FirstChild(grandchild) }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + _, ok, err := check.call() + if err != nil || ok { + t.Fatalf("ok = %v, error = %v", ok, err) + } + }) + } +} + +func TestAccessibleTreeInvalidatesSubtreeAndDetachesIt(t *testing.T) { + tree := testTree(t) + root, _ := tree.Root() + second, _, _ := tree.LastChild(root) + grandchild, _, _ := tree.FirstChild(second) + + if removed := tree.Invalidate(second.ID()); removed != 2 { + t.Fatalf("removed = %d, want 2", removed) + } + if _, err := tree.Resolve(second); !errors.Is(err, ErrStaleObject) { + t.Fatalf("second error = %v, want stale object", err) + } + if _, err := tree.Resolve(grandchild); !errors.Is(err, ErrStaleObject) { + t.Fatalf("grandchild error = %v, want stale object", err) + } + last, ok, err := tree.LastChild(root) + if err != nil || !ok || last.ID() != testID("/first") { + t.Fatalf("remaining last child = %#v, %v, %v", last, ok, err) + } + if removed := tree.Invalidate(second.ID()); removed != 0 { + t.Fatalf("second invalidation removed = %d, want 0", removed) + } +} + +func TestAccessibleTreeInvalidatesChildrenAndKeepsParent(t *testing.T) { + tree := testTree(t) + root, _ := tree.Root() + first, _, _ := tree.FirstChild(root) + second, _, _ := tree.LastChild(root) + if removed := tree.InvalidateChildren(root.ID()); removed != 3 { + t.Fatalf("removed = %d, want 3", removed) + } + if _, err := tree.Resolve(root); err != nil { + t.Fatalf("root became stale: %v", err) + } + if _, err := tree.Resolve(first); !errors.Is(err, ErrStaleObject) { + t.Fatalf("first error = %v, want stale object", err) + } + if _, err := tree.Resolve(second); !errors.Is(err, ErrStaleObject) { + t.Fatalf("second error = %v, want stale object", err) + } + if _, ok, err := tree.FirstChild(root); !errors.Is(err, ErrIncompleteTree) || ok { + t.Fatalf("root child after invalidation: ok = %v, error = %v", ok, err) + } +} + +func TestAccessibleTreeCopiesSubtreeIdentities(t *testing.T) { + tree := testTree(t) + first, err := tree.Reference(testID("/second")) + if err != nil { + t.Fatal(err) + } + ids, err := tree.subtreeIDs(first) + if err != nil { + t.Fatal(err) + } + want := map[ObjectID]bool{testID("/second"): true, testID("/grandchild"): true} + if len(ids) != len(want) { + t.Fatalf("subtree identities = %v", ids) + } + for _, id := range ids { + if !want[id] { + t.Fatalf("unexpected subtree identity %v", id) + } + } +} + +func TestAccessibleTreeReferenceGeneration(t *testing.T) { + tree := testTree(t) + first, err := tree.Reference(testID("/first")) + if err != nil { + t.Fatal(err) + } + updated := []TreeNode{ + testNode("/root", "Window", testID("/first")), + testNode("/first", "Updated"), + } + if err := tree.Replace(testID("/root"), updated); err != nil { + t.Fatal(err) + } + snapshot, err := tree.Resolve(first) + if err != nil || snapshot.Name != "Updated" { + t.Fatalf("stable reference resolved %#v, %v", snapshot, err) + } + + tree.Invalidate(first.ID()) + if err := tree.Replace(testID("/root"), updated); err != nil { + t.Fatal(err) + } + if _, err := tree.Resolve(first); !errors.Is(err, ErrStaleObject) { + t.Fatalf("reintroduced object resolved with old reference: %v", err) + } + reintroduced, err := tree.Reference(first.ID()) + if err != nil { + t.Fatal(err) + } + if reintroduced == first { + t.Fatal("reintroduced object reused its invalidated generation") + } +} + +func TestAccessibleTreeReplaceFailureIsAtomicAndChildrenAreOwned(t *testing.T) { + children := []ObjectID{testID("/child")} + tree, err := NewAccessibleTree(testID("/root"), []TreeNode{ + testNode("/root", "Root", children...), + testNode("/child", "Child"), + }) + if err != nil { + t.Fatal(err) + } + root, _ := tree.Root() + children[0] = testID("/changed") + child, ok, err := tree.FirstChild(root) + if err != nil || !ok || child.ID() != testID("/child") { + t.Fatalf("owned child = %#v, %v, %v", child, ok, err) + } + + err = tree.Replace(testID("/root"), []TreeNode{ + testNode("/root", "Broken", testID("/missing")), + }) + if !errors.Is(err, ErrInvalidTree) { + t.Fatalf("replace error = %v, want invalid tree", err) + } + snapshot, err := tree.Resolve(root) + if err != nil || snapshot.Name != "Root" { + t.Fatalf("tree changed after rejected replacement: %#v, %v", snapshot, err) + } +} + +func TestAccessibleTreeSeparatesSamePathOnDifferentBuses(t *testing.T) { + rootSnapshot := NewSnapshot("/same", "First application", "application", "", "") + rootSnapshot.BusName = ":1.10" + childSnapshot := NewSnapshot("/same", "Second application", "application", "", "") + childSnapshot.BusName = ":1.11" + childID := childSnapshot.ID() + tree, err := NewAccessibleTree(rootSnapshot.ID(), []TreeNode{ + {Snapshot: rootSnapshot, Children: []ObjectID{childID}}, + {Snapshot: childSnapshot}, + }) + if err != nil { + t.Fatal(err) + } + child, err := tree.Reference(childID) + if err != nil { + t.Fatal(err) + } + got, err := tree.Resolve(child) + if err != nil || got.Name != "Second application" { + t.Fatalf("snapshot = %#v, error = %v", got, err) + } +} + +func TestAccessibleTreeRejectsMalformedTrees(t *testing.T) { + root := testID("/root") + defunct := testNode("/root", "Defunct") + defunct.Snapshot.Defunct = true + tests := []struct { + name string + root ObjectID + nodes []TreeNode + }{ + {name: "empty root", nodes: []TreeNode{testNode("/root", "Root")}}, + {name: "empty tree", root: root}, + {name: "missing root", root: root, nodes: []TreeNode{testNode("/other", "Other")}}, + {name: "empty node identity", root: root, nodes: []TreeNode{{Snapshot: Snapshot{}}}}, + {name: "malformed node identity", root: root, nodes: []TreeNode{{Snapshot: Snapshot{BusName: "not unique", Path: "relative"}}}}, + {name: "defunct node", root: root, nodes: []TreeNode{defunct}}, + {name: "duplicate node", root: root, nodes: []TreeNode{testNode("/root", "One"), testNode("/root", "Two")}}, + {name: "missing child", root: root, nodes: []TreeNode{testNode("/root", "Root", testID("/missing"))}}, + {name: "duplicate child", root: root, nodes: []TreeNode{ + testNode("/root", "Root", testID("/child"), testID("/child")), testNode("/child", "Child"), + }}, + {name: "multiple parents", root: root, nodes: []TreeNode{ + testNode("/root", "Root", testID("/one"), testID("/two")), + testNode("/one", "One", testID("/child")), testNode("/two", "Two", testID("/child")), + testNode("/child", "Child"), + }}, + {name: "cycle", root: root, nodes: []TreeNode{ + testNode("/root", "Root", testID("/child")), testNode("/child", "Child", root), + }}, + {name: "disconnected", root: root, nodes: []TreeNode{ + testNode("/root", "Root"), testNode("/other", "Other"), + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewAccessibleTree(test.root, test.nodes) + if !errors.Is(err, ErrInvalidTree) { + t.Fatalf("error = %v, want invalid tree", err) + } + }) + } +} + +func TestAccessibleTreeRejectsOversizedTree(t *testing.T) { + nodes := make([]TreeNode, maxTreeNodes+1) + for index := range nodes { + nodes[index] = testNode(fmt.Sprintf("/%d", index), "Node") + } + _, err := NewAccessibleTree(testID("/0"), nodes) + if !errors.Is(err, ErrInvalidTree) { + t.Fatalf("error = %v, want invalid tree", err) + } +} + +func TestAccessibleTreeAcceptsLargeBrowserDocument(t *testing.T) { + const count = 10001 // The former limit rejected this complete document. + nodes := make([]TreeNode, count) + children := make([]ObjectID, 0, count-1) + for index := 1; index < count; index++ { + path := fmt.Sprintf("/%d", index) + children = append(children, testID(path)) + nodes[index] = testNode(path, "Heading") + nodes[index].Snapshot.Role = "heading" + } + nodes[0] = testNode("/0", "Document", children...) + nodes[0].Snapshot.Role = "document web" + tree, err := NewAccessibleTree(testID("/0"), nodes) + if err != nil { + t.Fatal(err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + document, err := tree.Document(t.Context(), root) + if err != nil || len(document.Nodes) != count { + t.Fatalf("large document: nodes=%d, err=%v", len(document.Nodes), err) + } +} + +func TestAccessibleTreeConcurrentInvalidation(t *testing.T) { + tree := testTree(t) + reference, err := tree.Reference(testID("/grandchild")) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + done := make(chan struct{}) + var wait sync.WaitGroup + for range 8 { + wait.Add(1) + go func() { + defer wait.Done() + <-start + for { + select { + case <-done: + return + default: + _, resolveErr := tree.Resolve(reference) + if resolveErr != nil && !errors.Is(resolveErr, ErrStaleObject) { + t.Errorf("resolve error = %v", resolveErr) + return + } + } + } + }() + } + close(start) + tree.Invalidate(testID("/second")) + close(done) + wait.Wait() + if _, err := tree.Resolve(reference); !errors.Is(err, ErrStaleObject) { + t.Fatalf("final resolve error = %v, want stale object", err) + } +} + +func TestAccessibleTreeInvalidatedEmptyChildrenRequireRecapture(t *testing.T) { + tree := testTree(t) + leaf, _ := tree.Reference(testID("/first")) + tree.InvalidateChildren(leaf.ID()) + for _, traverse := range []func(ObjectReference) (ObjectReference, bool, error){tree.FirstChild, tree.LastChild} { + if _, ok, err := traverse(leaf); ok || !errors.Is(err, ErrIncompleteTree) { + t.Fatalf("invalidated empty children: ok=%v err=%v", ok, err) + } + } + if _, err := tree.Resolve(leaf); err != nil { + t.Fatal(err) + } + if _, ok, err := tree.Parent(leaf); err != nil || !ok { + t.Fatalf("parent: %v %v", ok, err) + } + if err := tree.Replace(testID("/root"), []TreeNode{ + testNode("/root", "Window", testID("/first")), + testNode("/first", "First", testID("/added")), + testNode("/added", "Added"), + }); err != nil { + t.Fatal(err) + } + child, ok, err := tree.FirstChild(leaf) + if err != nil || !ok || child.ID() != testID("/added") { + t.Fatalf("recaptured child: %v %v %v", child, ok, err) + } +} diff --git a/internal/atspi/version.go b/internal/atspi/version.go new file mode 100644 index 0000000..253f7d5 --- /dev/null +++ b/internal/atspi/version.go @@ -0,0 +1,32 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +/* +#cgo pkg-config: atspi-2 +#include +*/ +import "C" + +import "fmt" + +func Version() string { + var major, minor, micro C.gint + C.atspi_get_version(&major, &minor, µ) + return fmt.Sprintf("%d.%d.%d", int(major), int(minor), int(micro)) +} diff --git a/internal/atspi/version_test.go b/internal/atspi/version_test.go new file mode 100644 index 0000000..a162920 --- /dev/null +++ b/internal/atspi/version_test.go @@ -0,0 +1,36 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package atspi + +import ( + "strconv" + "strings" + "testing" +) + +func TestVersionIsNumericTriple(t *testing.T) { + parts := strings.Split(Version(), ".") + if len(parts) != 3 { + t.Fatalf("Version() = %q, want major.minor.micro", Version()) + } + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + t.Fatalf("Version() component %q is not numeric: %v", part, err) + } + } +} diff --git a/internal/atspi/webscripts_native.go b/internal/atspi/webscripts_native.go new file mode 100644 index 0000000..85b8425 --- /dev/null +++ b/internal/atspi/webscripts_native.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#cgo pkg-config: atspi-2 gobject-2.0 +#include +static AtspiAccessible *web_ref(AtspiAccessible *source) { return g_object_ref(source); } +static AtspiAccessible *web_event_child(AtspiEvent *event) { + if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) return NULL; + GObject *child = g_value_get_object(&event->any_data); + return child && ATSPI_IS_ACCESSIBLE(child) ? ATSPI_ACCESSIBLE(child) : NULL; +} +static gchar *web_event_text(AtspiEvent *event) { + return G_IS_VALUE(&event->any_data) && G_VALUE_HOLDS_STRING(&event->any_data) + ? g_value_dup_string(&event->any_data) : NULL; +} +static void web_live_attributes(AtspiAccessible *source, gchar **live, gchar **containerLive) { + GHashTable *attributes = atspi_accessible_get_attributes(source, NULL); + if (!attributes) return; + *live = g_strdup(g_hash_table_lookup(attributes, "live")); + *containerLive = g_strdup(g_hash_table_lookup(attributes, "container-live")); + g_hash_table_unref(attributes); +} +*/ +import "C" + +import ( + "fmt" + "os" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/webscripts" +) + +// Process identity is copied once per provider bus on the GLib owner. A failed +// lookup remains unknown and never enables an application exception. +func (adapter *Adapter) applicationProcess(source *C.AtspiAccessible) string { + id := nativeObjectID(source) + if !id.Valid() { + return "" + } + if process, known := adapter.applicationProcesses[id.BusName]; known { + return process + } + pid := C.atspi_accessible_get_process_id(source, nil) + if pid == 0 { + return "" + } + content, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + return "" + } + process := strings.TrimSpace(string(content)) + if adapter.applicationProcesses == nil { + adapter.applicationProcesses = make(map[string]string) + } + if len(adapter.applicationProcesses) >= 64 { + clear(adapter.applicationProcesses) + } + adapter.applicationProcesses[id.BusName] = process + return process +} + +// Capture semantic roots only for a matched script and relevant event. Normal +// Chromium events do not incur ancestor scans or notification text traversal. +// No C pointer escapes this function; the callback still owns the event source. +func (adapter *Adapter) webNotification(event *C.AtspiEvent) *webscripts.Notification { + if !webscripts.NotificationEvent(C.GoString(event._type), int(event.detail1)) || + webscripts.MatchProcess(adapter.applicationProcess(event.source)) != webscripts.Steam { + return nil + } + source := event.source + if C.GoString(event._type) == ChildrenAddedEvent { + source = C.web_event_child(event) + } + if source == nil || nativeObjectID(source).BusName != nativeObjectID(event.source).BusName { + return nil + } + // Providers can emit insertion before the accessible-name update. Refresh + // only the changed object so an old cached name cannot replace new text. + if C.GoString(event._type) == TextInsertedEvent { + C.atspi_accessible_clear_cache_single(source) + } + deadline := time.Now().Add(50 * time.Millisecond) + var ancestors []*C.AtspiAccessible + var candidates []webscripts.NotificationCandidate + defer func() { + for _, ancestor := range ancestors { + C.g_object_unref(C.gpointer(ancestor)) + } + }() + for current := C.web_ref(source); current != nil; { + ancestors = append(ancestors, current) + role := C.atspi_accessible_get_role(current, nil) + candidate := webscripts.NotificationCandidate{} + switch role { + case C.ATSPI_ROLE_NOTIFICATION: + candidate.Role = "notification" + case C.ATSPI_ROLE_ALERT: + candidate.Role = "alert" + } + var live, containerLive *C.gchar + C.web_live_attributes(current, &live, &containerLive) + candidate.Live, candidate.ContainerLive = C.GoString(live), C.GoString(containerLive) + C.g_free(C.gpointer(live)) + C.g_free(C.gpointer(containerLive)) + candidates = append(candidates, candidate) + if candidate.Role != "" || role == C.ATSPI_ROLE_APPLICATION || + len(ancestors) >= 32 || time.Now().After(deadline) { + break + } + current = C.atspi_accessible_get_parent(current, nil) + } + index := webscripts.NotificationRoot(candidates) + if index < 0 { + return nil + } + root := ancestors[index] + id := nativeObjectID(root) + if !id.Valid() { + return nil + } + text := webNotificationText(root, ancestors[index+1:], deadline) + if text == "" && C.GoString(event._type) == TextInsertedEvent { + payload := C.web_event_text(event) + text = sanitizeSnapshotField(C.GoString(payload)) + C.g_free(C.gpointer(payload)) + } + return &webscripts.Notification{BusName: id.BusName, RootPath: id.Path, Text: text} +} + +func webReadableText(source *C.AtspiAccessible) string { + if C.atspi_accessible_get_role(source, nil) == C.ATSPI_ROLE_PASSWORD_TEXT { + return "" + } + name := C.atspi_accessible_get_name(source, nil) + content := sanitizeSnapshotField(C.GoString(name)) + C.g_free(C.gpointer(name)) + if content != "" { + return content + } + text := C.atspi_accessible_get_text_iface(source) + if text == nil { + return "" + } + defer C.g_object_unref(C.gpointer(text)) + count := min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength) + if count <= 0 { + return "" + } + value := C.atspi_text_get_text(text, 0, C.gint(count), nil) + content = sanitizeSnapshotField(strings.ReplaceAll(C.GoString(value), "\ufffc", "")) + C.g_free(C.gpointer(value)) + return content +} + +func webNotificationText(root *C.AtspiAccessible, ancestors []*C.AtspiAccessible, deadline time.Time) string { + if text := webReadableText(root); text != "" { + return text + } + var labels []string + var button, readable string + remaining := 64 + var visit func(*C.AtspiAccessible, int) + visit = func(source *C.AtspiAccessible, depth int) { + if remaining <= 0 || depth >= 8 || time.Now().After(deadline) { + return + } + remaining-- + role := C.atspi_accessible_get_role(source, nil) + if role == C.ATSPI_ROLE_PASSWORD_TEXT { + return + } + text := webReadableText(source) + if text != "" { + switch role { + case C.ATSPI_ROLE_LABEL, C.ATSPI_ROLE_STATIC, C.ATSPI_ROLE_TEXT: + labels = append(labels, text) + case C.ATSPI_ROLE_PUSH_BUTTON: + if button == "" { + button = text + } + } + if readable == "" { + readable = text + } + } + count := min(int(C.atspi_accessible_get_child_count(source, nil)), remaining) + for index := 0; index < count && remaining > 0 && time.Now().Before(deadline); index++ { + if child := C.atspi_accessible_get_child_at_index(source, C.gint(index), nil); child != nil { + visit(child, depth+1) + C.g_object_unref(C.gpointer(child)) + } + } + } + visit(root, 0) + if len(labels) != 0 { + return sanitizeSnapshotField(strings.Join(labels, " ")) + } + if button != "" { + return button + } + for _, ancestor := range ancestors { + if time.Now().After(deadline) { + break + } + if C.atspi_accessible_get_role(ancestor, nil) == C.ATSPI_ROLE_PUSH_BUTTON { + if text := webReadableText(ancestor); text != "" { + return text + } + } + } + return readable +} diff --git a/internal/atspi/webscripts_native_test.go b/internal/atspi/webscripts_native_test.go new file mode 100644 index 0000000..2431b73 --- /dev/null +++ b/internal/atspi/webscripts_native_test.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeWebScriptNotificationCapture(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_WEBSCRIPTS") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + adapterCtx, adapterCancel := context.WithTimeout(context.Background(), 25*time.Second) + defer adapterCancel() + adapter := New() + if err := adapter.Start(adapterCtx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + for _, processName := range []string{"steamwebhelper", "chromium"} { + t.Run(processName, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "python3", "../../tests/gtk_web_notification.py", processName) + 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) + } + defer func() { input.Close(); command.Process.Kill(); command.Wait() }() + ready, err := bufio.NewReader(output).ReadString('\n') + fields := strings.Fields(ready) + if err != nil || len(fields) != 2 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", ready, err) + } + // Enumerate the fixture once so GTK publishes its initially hidden + // alert accessibles before we request showing/text events. + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + // Keep the toast window outside foreground ownership. Events must + // carry copied notification data without acquiring native focus. + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil { + t.Fatalf("foreground focus unavailable: %s, %v", output, err) + } + fmt.Fprintln(input, "show") + if processName == "chromium" { + fmt.Fprintln(input, "update") + } + var root ObjectID + for !root.Valid() { + select { + case event := <-adapter.Events(): + if event.Source.ApplicationProcess != processName { + continue + } + if processName == "chromium" { + if event.Type != TextInsertedEvent || event.Payload != "Username is now online" { + continue + } + if event.WebNotification != nil { + t.Fatal("generic Chromium received Steam metadata") + } + return + } + if event.Type != ShowingStateEvent || event.Detail1 == 0 || event.Source.Role != "alert" { + continue + } + message := event.WebNotification + if !event.ForegroundMismatch { + t.Fatal("toast fixture was not outside foreground ownership") + } + if message == nil || message.Text != "Username Playing: Example Game" { + t.Fatalf("toast text: %+v", message) + } + root = NewObjectID(message.BusName, message.RootPath) + if root != event.Source.ID() { + t.Fatal("showing event did not use the alert root") + } + case <-ctx.Done(): + t.Fatal("toast showing event missing") + } + } + fmt.Fprintln(input, "update") + for { + select { + case event := <-adapter.Events(): + if event.Source.ApplicationProcess != processName || event.Type != TextInsertedEvent || event.Payload != "Username is now online" { + continue + } + message := event.WebNotification + if message == nil || NewObjectID(message.BusName, message.RootPath) != root || !strings.Contains(message.Text, "Username is now online") { + t.Fatalf("fragment did not recover complete alert ancestor: %+v", message) + } + fmt.Fprintln(input, "destroy") + for { + select { + case ending := <-adapter.Events(): + if ending.Type == DefunctStateEvent && ending.Source.ID() == root && ending.Detail1 != 0 { + fmt.Fprintln(input, "add") + for { + select { + case added := <-adapter.Events(): + if added.Type == ChildrenAddedEvent && added.Source.ApplicationProcess == processName && added.WebNotification != nil && added.WebNotification.RootPath != added.Source.Path { + if added.WebNotification.Text != "A new toast" { + t.Fatalf("child-added did not use the added toast root: %+v", added.WebNotification) + } + return + } + case <-ctx.Done(): + t.Fatal("added toast did not reach notification handling") + } + } + } + case <-ctx.Done(): + t.Fatal("destroyed toast root did not reach lifecycle handling") + } + } + case <-ctx.Done(): + t.Fatal("toast text insertion missing") + } + } + }) + } +} diff --git a/internal/atspi/window_title_native_test.go b/internal/atspi/window_title_native_test.go new file mode 100644 index 0000000..9367844 --- /dev/null +++ b/internal/atspi/window_title_native_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "testing" + "time" +) + +func TestNativeForegroundTitlePublication(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_WINDOW_TITLES") != "1" { + t.Skip("private Xvfb and D-Bus fixture only") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "python3", "-u", "../../tests/window_title_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("fixture did not start") + } + var windows map[string]uint64 + if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil { + t.Fatal(err) + } + adapter := New() + if err = adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + waitTitle := func(window, child uint64, text string) ForegroundTitle { + t.Helper() + for { + value := adapter.ForegroundTitle() + if value.Title.Window == window && value.Title.TitleWindow == child && value.Title.Title == text { + return value + } + select { + case <-adapter.TitleChanges(): + case <-ctx.Done(): + t.Fatalf("missing title %q: %+v", text, value) + } + } + } + command := func(action, window, title string) { + t.Helper() + data, _ := json.Marshal(map[string]string{"action": action, "window": window, "title": title}) + if _, err = stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("fixture command failed") + } + } + initial := waitTitle(windows["normal"], windows["normal"], "Example title") + command("title", "normal", "Changed without AT-SPI") + changed := waitTitle(windows["normal"], windows["normal"], "Changed without AT-SPI") + if changed.Epoch != initial.Epoch { + t.Fatal("title change reset foreground ownership") + } + command("activate", "desktop", "") + command("focus", "control", "") + wine := waitTitle(windows["desktop"], windows["game"], "Wine game") + if wine.Epoch == initial.Epoch || wine.Title.Application != "game.exe" { + t.Fatalf("Wine ownership = %+v", wine) + } + if !wine.Title.Wine || !wine.Title.Focused || !wine.Title.Client.Known || + wine.Title.Client.Window != windows["game"] || wine.Title.Client.Width != 250 || wine.Title.Client.Height != 150 { + t.Fatalf("Wine child geometry/focus missing: %+v", wine) + } + command("focus", "second", "") + dialog := waitTitle(windows["desktop"], windows["second"], "Wine dialog") + if dialog.Epoch != wine.Epoch { + t.Fatal("Wine child change reset desktop ownership") + } + command("destroy", "desktop", "") + empty := waitTitle(0, 0, "") + if empty.Epoch == wine.Epoch { + t.Fatal("lost foreground kept its epoch") + } +} diff --git a/internal/braille/braille.go b/internal/braille/braille.go new file mode 100644 index 0000000..c4eb8d8 --- /dev/null +++ b/internal/braille/braille.go @@ -0,0 +1,276 @@ +// Package braille provides the initial, device-independent BrlAPI diagnostic. +package braille + +import ( + "context" + "errors" + "fmt" + "io" + "runtime" + "strings" + "time" +) + +var ErrUnavailable = errors.New("BrlAPI support is unavailable; rebuild with -tags brlapi") + +type Geometry struct{ Columns, Rows int } + +func (g Geometry) Cells() (int, error) { + if g.Columns <= 0 || g.Rows <= 0 || g.Columns > 4096 || g.Rows > 4096/g.Columns { + return 0, errors.New("unsupported braille display geometry") + } + return g.Columns * g.Rows, nil +} + +type Key int + +// Frame is an uncontracted line. Cursor is a zero-based rune offset into Text; +// -1 hides the cursor. The helper converts it to BrlAPI's one-based cell. +type Frame struct { + Text string `json:"text"` + Cursor int `json:"cursor"` + ID uint64 `json:"id"` + Contract bool `json:"contract,omitempty"` + Table string `json:"table,omitempty"` + ExpandAtCursor bool `json:"expandAtCursor,omitempty"` + WordWrap bool `json:"wordWrap,omitempty"` + Dots []byte `json:"dots,omitempty"` + // Routing metadata stays in the parent, never in the helper protocol. + Routes []TextRoute `json:"-"` + Virtual *VirtualRoute `json:"-"` + Document *DocumentRoute `json:"-"` +} + +// DocumentRoute identifies the page and review epoch which produced a frame. +// Each TextRoute still maps cells to the individual native text object. +type DocumentRoute struct { + Epoch uint64 + FocusBus, FocusPath, DocumentBus, DocumentPath string +} + +// VirtualRoute maps a temporary text frame to a generation-owned cursor. +// End includes the synthetic end-of-line cell when the cursor is there. +type VirtualRoute struct { + Generation uint64 + Start, End, SourceStart int +} + +// TextRoute maps a half-open span of uncontracted cells to source characters. +// End-of-line indicator cells clamp to the end of SourceText. +type TextRoute struct { + Start, End, SourceStart, TextLength int + BusName, Path, SourceText string +} + +type RoutingEvent struct { + ID uint64 `json:"id"` + Offset int `json:"offset"` +} + +const ( + NoKey Key = iota + PanLeft + PanRight + // Route keys encode the zero-based physical cell as RouteBase + cell. + RouteBase Key = 0x10000 +) + +// Backend calls are serialized by the session owner. ReadKey must not wait +// for input. Only supported commands are claimed; other keys stay with BRLTTY. +type Backend interface { + Open() (Geometry, error) + Write(text string, cursor int) error // cursor: 0 hides it, otherwise one-based + ReadKey() (Key, error) + Close() error +} + +// viewport holds displayed cells and their source offsets for routing. +// Uncontracted text uses one Unicode rune per cell; liblouis supplies maps +// when a frame requests contraction. +type viewport struct { + text []rune + width, offset int + cursor int + id uint64 + inputPositions []int + wordWrap bool + dots []byte + panHistory []int +} + +func (v *viewport) replace(text string) { + v.setFrame(Frame{Text: text, Cursor: -1}) +} + +func (v *viewport) setFrame(frame Frame) error { + length := len([]rune(frame.Text)) + if frame.Cursor < -1 || frame.Cursor > length { + return errors.New("invalid braille cursor offset") + } + positions := make([]int, length) + if len(frame.Dots) != 0 && len(frame.Dots) != length { + return errors.New("invalid braille indicator length") + } + for i := range positions { + positions[i] = i + } + if frame.Contract && frame.Text != "" { + translated, mapped, cursor, err := translate(frame.Text, frame.Table, frame.Cursor, frame.ExpandAtCursor) + if err != nil { + return err + } + frame.Text, positions, frame.Cursor = translated, mapped, cursor + } + if frame.Cursor == len([]rune(frame.Text)) { + frame.Text += " " + positions = append(positions, length) + } + v.text = []rune(strings.Map(func(r rune) rune { + if r < 32 || r >= 127 && r < 160 { + return ' ' + } + return r + }, frame.Text)) + v.offset = 0 + v.cursor = frame.Cursor + v.id = frame.ID + v.inputPositions = positions + v.wordWrap = frame.WordWrap + v.panHistory = nil + v.dots = make([]byte, len(positions)) + for index, source := range positions { + end := source + 1 + if frame.Contract { + end = length + for _, next := range positions[index+1:] { + if next > source { + end = next + break + } + } + } + for input := source; input < min(end, len(frame.Dots)); input++ { + v.dots[index] |= frame.Dots[input] + } + } + if frame.Cursor >= v.width && v.width > 0 { + v.offset = frame.Cursor / v.width * v.width + } + return nil +} + +func (v *viewport) route(key Key) (RoutingEvent, bool) { + cell := int(key - RouteBase) + if key < RouteBase || cell < 0 || cell >= v.width || v.offset+cell >= v.visible_end() { + return RoutingEvent{}, false + } + return RoutingEvent{ID: v.id, Offset: v.inputPositions[v.offset+cell]}, true +} + +func (v *viewport) pan(key Key) bool { + previous := v.offset + switch key { + case PanLeft: + if len(v.panHistory) > 0 { + v.offset = v.panHistory[len(v.panHistory)-1] + v.panHistory = v.panHistory[:len(v.panHistory)-1] + } else { + v.offset = max(0, v.offset-v.width) + } + case PanRight: + if next := v.visible_end(); next < len(v.text) { + v.panHistory = append(v.panHistory, v.offset) + v.offset = next + } + } + return previous != v.offset +} + +func (v *viewport) frame() string { + end := v.visible_end() + return string(v.text[v.offset:end]) + strings.Repeat(" ", v.width-(end-v.offset)) +} + +func (v *viewport) displayDots() []byte { + result := make([]byte, v.width) + copy(result, v.dots[v.offset:v.visible_end()]) + return result +} + +func (v *viewport) displayCursor() int { + if v.cursor < v.offset || v.cursor >= v.visible_end() { + return 0 + } + return v.cursor - v.offset + 1 +} + +func braille_space(cell rune) bool { return cell == ' ' || cell == '\u2800' } + +// Pad the remainder of a wrapped page instead of showing half of the next +// word. Overlong words still advance by the display width. Do not hide a caret +// which the initial viewport was positioned to show. +func (v *viewport) visible_end() int { + end := min(len(v.text), v.offset+v.width) + if v.wordWrap && end < len(v.text) && !braille_space(v.text[end]) && + !braille_space(v.text[end-1]) { + for boundary := end - 1; boundary > v.offset; boundary-- { + if braille_space(v.text[boundary]) { + if v.cursor >= boundary+1 && v.cursor < end { + return end + } + return boundary + 1 + } + } + } + return end +} + +// Diagnostic uses only a generic fixture, never live application content. +// It owns the backend until cancellation or error and always releases it. +func Diagnostic(ctx context.Context, backend Backend, output io.Writer) (result error) { + if err := ctx.Err(); err != nil { + return err + } + // Keep every BrlAPI operation on the connection's original OS thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + geometry, err := backend.Open() + // Close also cleans up a partially opened connection. + defer func() { result = errors.Join(result, backend.Close()) }() + if err != nil { + return fmt.Errorf("connect to BRLTTY: %w", err) + } + cells, err := geometry.Cells() + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + v := viewport{width: cells} + v.replace("Cthulhu braille test. Pan right to read this sentence, then pan left to return.") + if err := backend.Write(v.frame(), 0); err != nil { + return err + } + if _, err := fmt.Fprintf(output, "BrlAPI connected: %d columns, %d rows. Use display pan keys; Ctrl+C exits.\n", geometry.Columns, geometry.Rows); err != nil { + return err + } + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + key, err := backend.ReadKey() + if err != nil { + return err + } + if v.pan(key) { + if err := backend.Write(v.frame(), 0); err != nil { + return err + } + } + } + } +} diff --git a/internal/braille/braille_test.go b/internal/braille/braille_test.go new file mode 100644 index 0000000..7314d20 --- /dev/null +++ b/internal/braille/braille_test.go @@ -0,0 +1,222 @@ +package braille + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +func TestViewportUnicodePanningAndReplacement(t *testing.T) { + v := viewport{width: 4} + v.replace("é界abcde") + if got := v.frame(); got != "é界ab" { + t.Fatalf("first frame: %q", got) + } + if v.pan(PanLeft) { + t.Fatal("panned before beginning") + } + if !v.pan(PanRight) || v.frame() != "cde " { + t.Fatalf("second frame: %q", v.frame()) + } + if v.pan(PanRight) { + t.Fatal("panned past end") + } + if !v.pan(PanLeft) || v.frame() != "é界ab" { + t.Fatal("could not return to first frame") + } + v.pan(PanRight) + v.replace("x") + if v.offset != 0 || v.frame() != "x " { + t.Fatalf("replacement retained old cells: %q", v.frame()) + } + v.replace("") + if v.frame() != " " { + t.Fatal("empty frame was not cleared") + } + v.replace("\x00\n\t\x7f") + if v.frame() != " " { + t.Fatal("control characters reached display") + } +} + +func TestViewportWordWrapAndIndicators(t *testing.T) { + v := viewport{width: 6} + if err := v.setFrame(Frame{Text: "one two three", Cursor: -1, WordWrap: true, + Dots: []byte{0, 0, 0, 0, 0x40, 0x40, 0, 0, 0, 0, 0, 0, 0}}); err != nil { + t.Fatal(err) + } + if v.frame() != "one " { + t.Fatalf("first wrapped frame=%q", v.frame()) + } + if !v.pan(PanRight) || v.frame() != "two " || v.offset != 4 || v.displayDots()[0] != 0x40 { + t.Fatalf("wrapped frame=%q offset=%d dots=%v", v.frame(), v.offset, v.displayDots()) + } + if !v.pan(PanLeft) || v.offset != 0 { + t.Fatalf("left offset=%d", v.offset) + } + if err := v.setFrame(Frame{Text: "abcdefghij", Cursor: -1, WordWrap: true}); err != nil { + t.Fatal(err) + } + if !v.pan(PanRight) || v.offset != 6 { + t.Fatalf("long word offset=%d", v.offset) + } +} + +func TestViewportCursorFollowsCaretAndPanning(t *testing.T) { + v := viewport{width: 4} + if err := v.setFrame(Frame{Text: "é界abcde", Cursor: 5}); err != nil { + t.Fatal(err) + } + if v.frame() != "cde " || v.displayCursor() != 2 { + t.Fatalf("caret page: frame=%q cursor=%d", v.frame(), v.displayCursor()) + } + if !v.pan(PanLeft) || v.displayCursor() != 0 { + t.Fatal("cursor remained visible outside the panned viewport") + } + if !v.pan(PanRight) || v.displayCursor() != 2 { + t.Fatal("cursor did not reappear on its page") + } + if err := v.setFrame(Frame{Text: "abcd", Cursor: 4}); err != nil { + t.Fatal(err) + } + if v.frame() != " " || v.displayCursor() != 1 { + t.Fatalf("end-of-line cell: frame=%q cursor=%d", v.frame(), v.displayCursor()) + } + for _, cursor := range []int{-2, 5} { + if err := v.setFrame(Frame{Text: "abcd", Cursor: cursor}); err == nil { + t.Fatalf("accepted cursor %d", cursor) + } + } +} + +func TestGeometry(t *testing.T) { + for _, g := range []Geometry{{0, 1}, {20, 0}, {-1, 1}, {4097, 1}, {1, 4097}, {4096, 4096}} { + if _, err := g.Cells(); err == nil { + t.Errorf("accepted %+v", g) + } + } + if cells, err := (Geometry{20, 2}).Cells(); err != nil || cells != 40 { + t.Fatalf("multirow: %d, %v", cells, err) + } +} + +type fakeBackend struct { + geometry Geometry + openErr, writeErr, readErr, closeErr error + opens, closes int + writes []string + cursors []int + keys []Key + cancel context.CancelFunc +} + +func (f *fakeBackend) Open() (Geometry, error) { f.opens++; return f.geometry, f.openErr } +func (f *fakeBackend) Write(text string, cursor int) error { + f.writes = append(f.writes, text) + f.cursors = append(f.cursors, cursor) + return f.writeErr +} +func (f *fakeBackend) ReadKey() (Key, error) { + if len(f.keys) > 0 { + key := f.keys[0] + f.keys = f.keys[1:] + return key, f.readErr + } + if f.cancel != nil { + f.cancel() + } + return NoKey, f.readErr +} +func (f *fakeBackend) Close() error { f.closes++; return f.closeErr } + +func TestDiagnosticPansAndReleases(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f := &fakeBackend{geometry: Geometry{20, 1}, keys: []Key{PanRight, PanLeft}, cancel: cancel} + var output bytes.Buffer + if err := Diagnostic(ctx, f, &output); err != nil { + t.Fatal(err) + } + if f.opens != 1 || f.closes != 1 || len(f.writes) != 3 { + t.Fatalf("lifecycle: %+v", f) + } + if f.writes[0] != f.writes[2] || f.writes[0] == f.writes[1] { + t.Fatalf("pan frames: %q", f.writes) + } + if !strings.Contains(output.String(), "20 columns, 1 rows") { + t.Fatal(output.String()) + } +} + +func TestDiagnosticFailureCleanup(t *testing.T) { + failure := errors.New("fake failure") + for _, stage := range []string{"open", "geometry", "write", "read", "close"} { + t.Run(stage, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f := &fakeBackend{geometry: Geometry{20, 1}, cancel: cancel} + switch stage { + case "open": + f.openErr = failure + case "geometry": + f.geometry = Geometry{} + case "write": + f.writeErr = failure + case "read": + f.readErr = failure + case "close": + f.closeErr = failure + } + err := Diagnostic(ctx, f, io.Discard) + if err == nil { + t.Fatal("failure was lost") + } + if stage != "geometry" && !errors.Is(err, failure) { + t.Fatalf("lost cause: %v", err) + } + if f.closes != 1 { + t.Fatalf("close count: %d", f.closes) + } + if (stage == "open" || stage == "geometry") && len(f.writes) > 0 { + t.Fatal("wrote without a valid display") + } + }) + } +} + +func TestDiagnosticCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + f := &fakeBackend{} + if err := Diagnostic(ctx, f, io.Discard); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if f.opens != 0 { + t.Fatal("opened after cancellation") + } +} + +func TestViewportRoutingRejectsPaddingAndTracksReplacement(t *testing.T) { + v := viewport{width: 4} + if err := v.setFrame(Frame{ID: 1, Text: "é猫abc", Cursor: -1}); err != nil { + t.Fatal(err) + } + v.pan(PanRight) + if got, ok := v.route(RouteBase); !ok || got != (RoutingEvent{ID: 1, Offset: 4}) { + t.Fatalf("route %+v %v", got, ok) + } + for _, key := range []Key{PanLeft, RouteBase + 1, RouteBase + 4, RouteBase - 1} { + if _, ok := v.route(key); ok { + t.Fatalf("routed invalid cell %d", key) + } + } + if err := v.setFrame(Frame{ID: 2, Text: "new", Cursor: -1}); err != nil { + t.Fatal(err) + } + if got, ok := v.route(RouteBase + 1); !ok || got != (RoutingEvent{ID: 2, Offset: 1}) { + t.Fatalf("replacement route %+v %v", got, ok) + } +} diff --git a/internal/braille/client.go b/internal/braille/client.go new file mode 100644 index 0000000..b81ad73 --- /dev/null +++ b/internal/braille/client.go @@ -0,0 +1,120 @@ +package braille + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "runtime" + "time" +) + +// RunClient owns BrlAPI inside the disposable helper process. The parent can +// terminate this process if a native call blocks or BRLTTY disconnects. +func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready io.Writer) (result error) { + // Keep the session on one OS thread. Migration between Open and ReadKey + // produced an unexpected EOF with the live BRLTTY connection. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + geometry, err := backend.Open() + defer func() { result = errors.Join(result, backend.Close()) }() + if err != nil { + return err + } + cells, err := geometry.Cells() + if err != nil { + return err + } + if _, err := io.WriteString(ready, "ready\n"); err != nil { + return err + } + frames := make(chan Frame, 1) + readErrors := make(chan error, 1) + readDone := make(chan struct{}) + // The session owns its input reader: close unblocks Scan, then join it. + defer func() { _ = input.Close(); <-readDone }() + go func() { + defer close(readDone) + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 4096), 65536) + for scanner.Scan() { + var frame Frame + if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { + readErrors <- fmt.Errorf("decode braille frame: %w", err) + return + } + offerLatestFrame(frames, frame) + } + readErrors <- scanner.Err() + }() + v := viewport{width: cells} + encoder := json.NewEncoder(ready) + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + inputClosed := false + for { + if inputClosed && len(frames) == 0 { + return nil + } + select { + case <-ctx.Done(): + return nil + case err := <-readErrors: + if err != nil { + return err + } + inputClosed = true + readErrors = nil + case frame := <-frames: + if err := v.setFrame(frame); err != nil { + return err + } + if err := writeViewport(backend, &v); err != nil { + return err + } + case <-ticker.C: + key, err := backend.ReadKey() + if err != nil { + return err + } + if v.pan(key) { + if err := writeViewport(backend, &v); err != nil { + return err + } + } + if event, ok := v.route(key); ok { + if err := encoder.Encode(event); err != nil { + return err + } + } + } + } +} + +type dotWriter interface { + WriteWithDots(string, int, []byte) error +} + +func writeViewport(backend Backend, viewport *viewport) error { + if writer, ok := backend.(dotWriter); ok { + return writer.WriteWithDots(viewport.frame(), viewport.displayCursor(), viewport.displayDots()) + } + return backend.Write(viewport.frame(), viewport.displayCursor()) +} + +// The consumer may remove the old frame after the first nonblocking send +// fails. Draining must therefore also be nonblocking. +func offerLatestFrame(frames chan Frame, frame Frame) { + select { + case frames <- frame: + return + default: + } + select { + case <-frames: + default: + } + frames <- frame +} diff --git a/internal/braille/client_test.go b/internal/braille/client_test.go new file mode 100644 index 0000000..7e22925 --- /dev/null +++ b/internal/braille/client_test.go @@ -0,0 +1,111 @@ +package braille + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + "time" +) + +type clientBackend struct { + fakeBackend + cancel context.CancelFunc +} + +func (backend *clientBackend) Write(text string, cursor int) error { + if err := backend.fakeBackend.Write(text, cursor); err != nil { + return err + } + backend.cancel() + return nil +} + +func TestRunClientShowsLiveFrameAndReleasesDisplay(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + backend := &clientBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, cancel: cancel} + input := strings.NewReader("{\"text\":\"Save, push button\",\"cursor\":-1}\n") + var ready bytes.Buffer + if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil { + t.Fatal(err) + } + if ready.String() != "ready\n" || len(backend.writes) != 1 || + backend.writes[0] != "Save, push button " || backend.opens != 1 || backend.closes != 1 { + t.Fatalf("client lifecycle: ready=%q backend=%+v", ready.String(), backend) + } +} + +func TestRunClientPlacesCursorOnUnicodeCaret(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + backend := &clientBackend{fakeBackend: fakeBackend{geometry: Geometry{4, 1}}, cancel: cancel} + input := strings.NewReader("{\"text\":\"é界abcde\",\"cursor\":5}\n") + if err := RunClient(ctx, backend, io.NopCloser(input), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if len(backend.writes) != 1 || backend.writes[0] != "cde " || + len(backend.cursors) != 1 || backend.cursors[0] != 2 { + t.Fatalf("writes=%q cursors=%v", backend.writes, backend.cursors) + } +} + +func TestRunClientRejectsInvalidCursorAndCloses(t *testing.T) { + backend := &fakeBackend{geometry: Geometry{4, 1}} + err := RunClient(context.Background(), backend, + io.NopCloser(strings.NewReader("{\"text\":\"abc\",\"cursor\":4}\n")), &bytes.Buffer{}) + if err == nil || backend.opens != 1 || backend.closes != 1 || len(backend.writes) != 0 { + t.Fatalf("error=%v backend=%+v", err, backend) + } +} + +func TestOfferLatestFrameReplacesPendingFrame(t *testing.T) { + frames := make(chan Frame, 1) + offerLatestFrame(frames, Frame{Text: "old"}) + offerLatestFrame(frames, Frame{Text: "new"}) + if frame := <-frames; frame.Text != "new" { + t.Fatalf("retained %q", frame.Text) + } + offerLatestFrame(frames, Frame{Text: "newer"}) + if frame := <-frames; frame.Text != "newer" { + t.Fatalf("lost empty-queue update: %q", frame.Text) + } +} + +type routingBackend struct{ fakeBackend } + +func (b *routingBackend) ReadKey() (Key, error) { + if len(b.writes) == 0 { + return NoKey, nil + } + return b.fakeBackend.ReadKey() +} + +func TestRunClientRoutesDisplayedFrameAfterPanningAndJoinsReader(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + input, writer := io.Pipe() + defer writer.Close() + backend := &routingBackend{fakeBackend{geometry: Geometry{4, 1}, keys: []Key{PanRight, RouteBase + 1}, cancel: cancel}} + var output bytes.Buffer + done := make(chan error, 1) + go func() { done <- RunClient(ctx, backend, input, &output) }() + if _, err := io.WriteString(writer, "{\"text\":\"é猫abcdef\",\"cursor\":-1,\"id\":42}\n"); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if output.String() != "ready\n{\"id\":42,\"offset\":5}\n" { + t.Fatalf("helper output %q", output.String()) + } + if backend.closes != 1 { + t.Fatalf("close count %d", backend.closes) + } + // The writer has deliberately remained open; return requires the session to + // close the blocked input reader and join it. + if _, err := writer.Write([]byte("x")); err == nil { + t.Fatal("reader still open") + } +} diff --git a/internal/braille/native.go b/internal/braille/native.go new file mode 100644 index 0000000..0581b33 --- /dev/null +++ b/internal/braille/native.go @@ -0,0 +1,225 @@ +//go:build brlapi && cgo + +package braille + +/* +#cgo LDFLAGS: -lbrlapi +#include +#include +#include +#include + +typedef struct cth_brl cth_brl; +struct cth_brl { + brlapi_handle_t *handle; + unsigned int columns, rows; + int opened, entered, failed; +}; + +// The default BrlAPI handler terminates the process and can dump a packet. +// Record a failure instead; never print display contents or packet payloads. +static void cth_exception(brlapi_handle_t *handle, int error, + brlapi_packetType_t type, const void *packet, size_t size) { + cth_brl *owner = brlapi__getClientData(handle); + if (owner) owner->failed = 1; +} + +static cth_brl *cth_alloc(void) { + cth_brl *s = calloc(1, sizeof(*s)); + if (!s) return NULL; + s->handle = calloc(1, brlapi_getHandleSize()); + if (!s->handle) { free(s); return NULL; } + return s; +} + +static int cth_open(cth_brl *s, int routing) { + // NULL settings use BrlAPI's normal local connection/authentication. + if (brlapi__openConnection(s->handle, NULL, NULL) < 0) return -1; + s->opened = 1; + brlapi__setClientData(s->handle, s); + brlapi__setExceptionHandler(s->handle, cth_exception); + if (brlapi__getDisplaySize(s->handle, &s->columns, &s->rows) < 0 || s->failed) return -1; + if (!s->columns || !s->rows || s->columns > 4096 || s->rows > 4096/s->columns) return -2; + // Screen readers take the root of their terminal path. BrlAPI prepends + // WINDOWPATH or XDG_VTNR, so this follows the active graphical session. + if (brlapi__enterTtyModeWithPath(s->handle, NULL, 0, NULL) < 0 || s->failed) return -3; + s->entered = 1; + if (brlapi__ignoreAllKeys(s->handle) < 0 || s->failed) return -4; + brlapi_keyCode_t keys[] = { + BRLAPI_KEY_TYPE_CMD | BRLAPI_KEY_CMD_FWINLT, + BRLAPI_KEY_TYPE_CMD | BRLAPI_KEY_CMD_FWINRT + }; + if (brlapi__acceptKeys(s->handle, brlapi_rangeType_key, keys, 2) < 0 || s->failed) return -4; + if (routing) { + brlapi_keyCode_t route = BRLAPI_KEY_TYPE_CMD | BRLAPI_KEY_CMD_ROUTE; + if (brlapi__acceptKeys(s->handle, brlapi_rangeType_command, &route, 1) < 0 || s->failed) return -4; + } + return 0; +} + +static int cth_write(cth_brl *s, const char *text, int size, int cursor, + const unsigned char *andMask, const unsigned char *orMask) { + if (s->failed) return -1; + brlapi_writeArguments_t args = BRLAPI_WRITEARGUMENTS_INITIALIZER; + args.regionBegin = 1; + args.regionSize = s->columns * s->rows; + args.text = text; + args.textSize = size; + args.charset = "UTF-8"; + args.cursor = cursor; + args.andMask = andMask; + args.orMask = orMask; + int result = brlapi__write(s->handle, &args); + return s->failed ? -1 : result; +} + +static int cth_read(cth_brl *s, char *error, size_t errorSize) { + if (s->failed) return -1; + brlapi_keyCode_t code; + int result = brlapi__readKey(s->handle, 0, &code); + if (s->failed) return -1; + if (result < 0 && brlapi_errno == BRLAPI_ERROR_LIBCERR && brlapi_libcerrno == EINTR) return 0; + if (result < 0) brlapi_strerror_r(&brlapi_error, error, errorSize); + if (result <= 0) return result; + if ((code & BRLAPI_KEY_TYPE_MASK) != BRLAPI_KEY_TYPE_CMD) return 0; + if ((code & BRLAPI_KEY_CMD_BLK_MASK) == BRLAPI_KEY_CMD_ROUTE) + return 0x10000 + (code & BRLAPI_KEY_CMD_ARG_MASK); + switch (code & BRLAPI_KEY_CODE_MASK) { + case BRLAPI_KEY_CMD_FWINLT: return 1; + case BRLAPI_KEY_CMD_FWINRT: return 2; + default: return 0; + } +} + +static int cth_close(cth_brl *s) { + int result = 0; + if (s->entered && !s->failed) result = brlapi__leaveTtyMode(s->handle); + if (s->failed) result = -1; + if (s->opened) brlapi__closeConnection(s->handle); + free(s->handle); + free(s); + return result; +} +*/ +import "C" + +import ( + "errors" + "sync" + "unicode/utf8" + "unsafe" +) + +type native struct { + mu sync.Mutex + session *C.cth_brl + cells int + routing bool +} + +func New() Backend { return &native{} } + +// NewRouting claims routing keys as well as pan commands for the live helper. +func NewRouting() Backend { return &native{routing: true} } + +func (n *native) Open() (Geometry, error) { + n.mu.Lock() + defer n.mu.Unlock() + if n.session != nil { + return Geometry{}, errors.New("BrlAPI connection already open") + } + s := C.cth_alloc() + if s == nil { + return Geometry{}, errors.New("allocate BrlAPI connection") + } + routing := C.int(0) + if n.routing { + routing = 1 + } + if code := C.cth_open(s, routing); code != 0 { + C.cth_close(s) + switch code { + case -2: + return Geometry{}, errors.New("unsupported braille display geometry") + case -3: + return Geometry{}, errors.New("acquire BrlAPI terminal path; check WINDOWPATH, XDG_VTNR, and terminal ownership") + case -4: + return Geometry{}, errors.New("configure BrlAPI commands") + default: + return Geometry{}, errors.New("BrlAPI connection or display query failed; check BRLTTY and authorization") + } + } + n.session = s + g := Geometry{Columns: int(s.columns), Rows: int(s.rows)} + n.cells, _ = g.Cells() + return g, nil +} + +func (n *native) Write(text string, cursor int) error { + return n.WriteWithDots(text, cursor, nil) +} + +func (n *native) WriteWithDots(text string, cursor int, dots []byte) error { + n.mu.Lock() + defer n.mu.Unlock() + if n.session == nil { + return errors.New("BrlAPI connection is closed") + } + if !utf8.ValidString(text) || utf8.RuneCountInString(text) != n.cells || cursor < 0 || cursor > n.cells { + return errors.New("invalid braille frame") + } + if len(dots) != 0 && len(dots) != n.cells { + return errors.New("invalid braille indicator length") + } + value := C.CString(text) + defer C.free(unsafe.Pointer(value)) + andMask := make([]byte, n.cells) + orMask := make([]byte, n.cells) + for index, cell := range []rune(text) { + andMask[index] = 0xff + if len(dots) != 0 { + orMask[index] = dots[index] + } + if cell >= 0x2800 && cell <= 0x28ff { + andMask[index] = 0 + orMask[index] |= byte(cell - 0x2800) + } + } + if C.cth_write(n.session, value, C.int(len(text)), C.int(cursor), + (*C.uchar)(unsafe.Pointer(&andMask[0])), (*C.uchar)(unsafe.Pointer(&orMask[0]))) < 0 { + return errors.New("BrlAPI display write failed") + } + return nil +} + +func (n *native) ReadKey() (Key, error) { + n.mu.Lock() + defer n.mu.Unlock() + if n.session == nil { + return NoKey, errors.New("BrlAPI connection is closed") + } + var detail [256]C.char + key := C.cth_read(n.session, &detail[0], C.size_t(len(detail))) + if key < 0 { + if detail[0] != 0 { + return NoKey, errors.New("BrlAPI key read failed: " + C.GoString(&detail[0])) + } + return NoKey, errors.New("BrlAPI key read failed after protocol exception") + } + return Key(key), nil +} + +func (n *native) Close() error { + n.mu.Lock() + defer n.mu.Unlock() + if n.session == nil { + return nil + } + result := C.cth_close(n.session) + n.session = nil + n.cells = 0 + if result < 0 { + return errors.New("release BrlAPI terminal failed") + } + return nil +} diff --git a/internal/braille/native_test.go b/internal/braille/native_test.go new file mode 100644 index 0000000..8989953 --- /dev/null +++ b/internal/braille/native_test.go @@ -0,0 +1,20 @@ +//go:build brlapi && cgo + +package braille + +import "testing" + +func TestNativeClosedLifecycle(t *testing.T) { + backend := New() + if err := backend.Write("", 0); err == nil { + t.Fatal("write on closed connection succeeded") + } + if _, err := backend.ReadKey(); err == nil { + t.Fatal("read on closed connection succeeded") + } + for range 2 { + if err := backend.Close(); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/braille/regions.go b/internal/braille/regions.go new file mode 100644 index 0000000..2953524 --- /dev/null +++ b/internal/braille/regions.go @@ -0,0 +1,42 @@ +package braille + +import ( + "errors" + "strings" + "unicode/utf8" +) + +// Region is one semantic piece of a braille line. Cursor is a zero-based rune +// offset within Text, including the position just after the final rune. +type Region struct { + Text string + Cursor int +} + +// Line follows Python Cthulhu's Line: regions are concatenated exactly as +// supplied and one region may own the visible cursor. A focus of -1 hides it. +type Line struct { + Regions []Region + Focus int +} + +func (line Line) Frame() (Frame, error) { + if line.Focus < -1 || line.Focus >= len(line.Regions) { + return Frame{}, errors.New("invalid focused braille region") + } + var text strings.Builder + cursor := -1 + position := 0 + for index, region := range line.Regions { + length := utf8.RuneCountInString(region.Text) + if index == line.Focus { + if region.Cursor < 0 || region.Cursor > length { + return Frame{}, errors.New("invalid braille region cursor") + } + cursor = position + region.Cursor + } + text.WriteString(region.Text) + position += length + } + return Frame{Text: text.String(), Cursor: cursor}, nil +} diff --git a/internal/braille/regions_test.go b/internal/braille/regions_test.go new file mode 100644 index 0000000..702fa77 --- /dev/null +++ b/internal/braille/regions_test.go @@ -0,0 +1,35 @@ +package braille + +import ( + "reflect" + "testing" +) + +func TestLineComposesFocusedRegionWithoutChangingSpacing(t *testing.T) { + line := Line{Regions: []Region{ + {Text: "Context "}, + {Text: "é界", Cursor: 1}, + {Text: " entry"}, + }, Focus: 1} + frame, err := line.Frame() + if err != nil || !reflect.DeepEqual(frame, Frame{Text: "Context é界 entry", Cursor: 9}) { + t.Fatalf("frame=%+v err=%v", frame, err) + } + line.Focus = -1 + frame, err = line.Frame() + if err != nil || frame.Cursor != -1 { + t.Fatalf("unfocused frame=%+v err=%v", frame, err) + } +} + +func TestLineRejectsInvalidFocusAndCursor(t *testing.T) { + for _, line := range []Line{ + {Focus: 0}, + {Regions: []Region{{Text: "x"}}, Focus: -2}, + {Regions: []Region{{Text: "x", Cursor: 2}}, Focus: 0}, + } { + if _, err := line.Frame(); err == nil { + t.Fatalf("accepted invalid line %+v", line) + } + } +} diff --git a/internal/braille/tables.go b/internal/braille/tables.go new file mode 100644 index 0000000..105fa81 --- /dev/null +++ b/internal/braille/tables.go @@ -0,0 +1,56 @@ +package braille + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +const tableDirectory = "/usr/share/liblouis/tables" + +type TableOption struct { + Label string + File string +} + +// AvailableTables lists installed literary and computer tables for the +// current message locale. English US also offers UEB tables. +func AvailableTables() []TableOption { + locale := "" + for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { + if value := os.Getenv(key); value != "" { + locale = value + break + } + } + return tablesForLocale(tableDirectory, locale) +} + +func tablesForLocale(dir, locale string) []TableOption { + locale = strings.ToLower(strings.ReplaceAll(strings.Split(strings.Split(locale, ".")[0], "@")[0], "_", "-")) + if locale == "" || locale == "c" || locale == "posix" { + locale = "en-us" + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var result []TableOption + for _, entry := range entries { + name := entry.Name() + lower := strings.ToLower(name) + if entry.IsDir() || !(strings.HasSuffix(lower, ".ctb") || strings.HasSuffix(lower, ".utb")) || + strings.Contains(lower, "interline") || strings.Contains(lower, "mathtext") || + !(strings.HasPrefix(lower, locale+"-") || locale == "en-us" && strings.HasPrefix(lower, "en-ueb-")) { + continue + } + label := strings.TrimSuffix(strings.TrimSuffix(name, ".ctb"), ".utb") + label = strings.ReplaceAll(label, "-g1", " Grade 1") + label = strings.ReplaceAll(label, "-g2", " Grade 2") + label = strings.ReplaceAll(label, "-g3", " Grade 3") + result = append(result, TableOption{Label: label, File: filepath.Join(dir, name)}) + } + sort.Slice(result, func(i, j int) bool { return result[i].Label < result[j].Label }) + return result +} diff --git a/internal/braille/tables_test.go b/internal/braille/tables_test.go new file mode 100644 index 0000000..110f29a --- /dev/null +++ b/internal/braille/tables_test.go @@ -0,0 +1,23 @@ +package braille + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTablesForLocaleIncludesUSEnglishAndUEB(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"en-us-g1.ctb", "en-us-g2.ctb", "en-ueb-g2.ctb", "en-us-interline.ctb", "en-gb-g1.utb", "fr-fr-g2.ctb"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0600); err != nil { + t.Fatal(err) + } + } + options := tablesForLocale(dir, "en_US.UTF-8") + if len(options) != 3 || options[0].Label != "en-ueb Grade 2" || options[2].File != filepath.Join(dir, "en-us-g2.ctb") { + t.Fatalf("US tables = %+v", options) + } + if options = tablesForLocale(dir, "fr_FR.UTF-8"); len(options) != 1 || options[0].Label != "fr-fr Grade 2" { + t.Fatalf("French tables = %+v", options) + } +} diff --git a/internal/braille/translate_native.go b/internal/braille/translate_native.go new file mode 100644 index 0000000..6923bc7 --- /dev/null +++ b/internal/braille/translate_native.go @@ -0,0 +1,117 @@ +//go:build brlapi && cgo + +package braille + +/* +#cgo pkg-config: liblouis +#include +#include +*/ +import "C" + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "unsafe" +) + +// translate retains liblouis's output-to-input positions so a routing key +// addresses the original text rather than a cell in the shortened line. +func translate(text, table string, cursor int, expand bool) (string, []int, int, error) { + if table == "" { + table = defaultContractionTable() + } + cTable := C.CString(table) + defer C.free(unsafe.Pointer(cTable)) + if C.lou_checkTable(cTable) == 0 { + return "", nil, 0, fmt.Errorf("invalid liblouis table %q", table) + } + source := []rune(text) + in := make([]C.widechar, len(source)) + for index, value := range source { + in[index] = C.widechar(value) + } + // Supply a bounded output buffer; translation fails if a table needs more. + out := make([]C.widechar, len(source)*8+32) + inputPos := make([]C.int, len(out)) + outputPos := make([]C.int, len(in)) + inLength, outLength := C.int(len(in)), C.int(len(out)) + cursorPos := C.int(cursor) + mode := C.int(C.dotsIO) + if expand && cursor >= 0 && cursor < len(source) && source[cursor] != ' ' { + mode |= C.compbrlAtCursor + } + if C.lou_translate(cTable, &in[0], &inLength, &out[0], &outLength, nil, nil, + &outputPos[0], &inputPos[0], &cursorPos, mode) == 0 { + return "", nil, 0, errors.New("liblouis translation failed") + } + if int(inLength) != len(in) || outLength < 0 || int(outLength) > len(out) { + return "", nil, 0, errors.New("liblouis returned invalid translation lengths") + } + result := make([]rune, int(outLength)) + positions := make([]int, int(outLength)) + for index := range result { + if out[index]&0x8000 == 0 || out[index]&^C.widechar(0x80ff) != 0 { + return "", nil, 0, errors.New("liblouis returned invalid braille dots") + } + result[index] = 0x2800 + rune(out[index]&0xff) + positions[index] = int(inputPos[index]) + if positions[index] < 0 || positions[index] >= len(source) { + return "", nil, 0, errors.New("liblouis returned invalid position map") + } + } + if cursor == len(source) { + cursorPos = outLength + } + if cursor < 0 { + cursorPos = -1 + } + if cursorPos < -1 || cursorPos > outLength { + return "", nil, 0, errors.New("liblouis returned invalid cursor") + } + return string(result), positions, int(cursorPos), nil +} + +func defaultContractionTable() string { + locale := "" + for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { + if value := os.Getenv(name); value != "" { + locale = value + break + } + } + locale = strings.ToLower(strings.ReplaceAll(strings.Split(strings.Split(locale, ".")[0], "@")[0], "_", "-")) + if locale == "" || locale == "c" || locale == "posix" { + locale = "en-us" + } + entries, err := os.ReadDir("/usr/share/liblouis/tables") + if err != nil { + return "en-us-g2.ctb" + } + var candidates []string + for _, entry := range entries { + name := strings.ToLower(entry.Name()) + if entry.IsDir() || !strings.HasPrefix(name, locale+"-") || + !(strings.HasSuffix(name, ".ctb") || strings.HasSuffix(name, ".utb")) || + strings.Contains(name, "interline") || strings.Contains(name, "mathtext") { + continue + } + candidates = append(candidates, entry.Name()) + } + sort.Strings(candidates) + for _, grade := range []string{"g2", "g1", "comp6", "comp8"} { + for _, name := range candidates { + if strings.Contains(name, grade) { + return filepath.Join("/usr/share/liblouis/tables", name) + } + } + } + if len(candidates) > 0 { + return filepath.Join("/usr/share/liblouis/tables", candidates[0]) + } + return "en-us-g2.ctb" +} diff --git a/internal/braille/translate_native_test.go b/internal/braille/translate_native_test.go new file mode 100644 index 0000000..747cf6f --- /dev/null +++ b/internal/braille/translate_native_test.go @@ -0,0 +1,73 @@ +//go:build brlapi && cgo + +package braille + +import "testing" + +func TestContractedWordWrapAndPartialSelection(t *testing.T) { + v := viewport{width: 4} + if err := v.setFrame(Frame{Text: "the cat", Cursor: -1, Contract: true, + Table: "en-us-g2.ctb", WordWrap: true, Dots: []byte{0, 0x40, 0, 0, 0, 0, 0}}); err != nil { + t.Fatal(err) + } + if v.frame() != "⠮⠀ " || v.displayDots()[0] != 0x40 { + t.Fatalf("wrapped contraction=%q dots=%v", v.frame(), v.displayDots()) + } + if _, ok := v.route(RouteBase + 2); ok { + t.Fatal("padding cell was routed into undisplayed text") + } + if !v.pan(PanRight) || v.frame() != "⠉⠁⠞ " || v.offset != 2 { + t.Fatalf("panned word=%q offset=%d", v.frame(), v.offset) + } + if !v.pan(PanLeft) || v.offset != 0 { + t.Fatal("wrapped pan left did not restore its page") + } +} + +func TestContractionMapsCursorAndRouting(t *testing.T) { + for _, cursor := range []int{-1, 0, 4, len([]rune("the cat"))} { + v := viewport{width: 4} + if err := v.setFrame(Frame{ID: 7, Text: "the cat", Cursor: cursor, Contract: true, Table: "en-us-g2.ctb"}); err != nil { + t.Fatal(err) + } + if cursor == -1 && (string(v.text) != "⠮⠀⠉⠁⠞" || v.inputPositions[0] != 0 || v.inputPositions[1] != 3) { + t.Fatalf("unexpected translation %q, positions %v", string(v.text), v.inputPositions) + } + if cursor == -1 { + if event, ok := v.route(RouteBase + 1); !ok || event != (RoutingEvent{ID: 7, Offset: 3}) { + t.Fatalf("contracted route = %+v, %v", event, ok) + } + if !v.pan(PanRight) { + t.Fatal("contracted line did not pan") + } + if event, ok := v.route(RouteBase); !ok || event != (RoutingEvent{ID: 7, Offset: 6}) { + t.Fatalf("panned contracted route = %+v, %v", event, ok) + } + } + if len(v.text) >= len([]rune("the cat")) && cursor < 0 { + t.Fatalf("text was not contracted: %q", string(v.text)) + } + for cell, original := range v.inputPositions { + if original < 0 || original > len([]rune("the cat")) { + t.Fatalf("cell %d has invalid source offset %d", cell, original) + } + } + if cursor == len([]rune("the cat")) && v.cursor != len(v.text)-1 { + t.Fatalf("end cursor %d, text %q", v.cursor, string(v.text)) + } + } + if _, _, _, err := translate("the", "missing-table.ctb", -1, false); err == nil { + t.Fatal("missing table accepted") + } + plain, _, _, err := translate("the cat", "en-us-g2.ctb", 0, false) + if err != nil { + t.Fatal(err) + } + expanded, _, _, err := translate("the cat", "en-us-g2.ctb", 0, true) + if err != nil { + t.Fatal(err) + } + if plain == expanded { + t.Fatalf("computer braille at cursor did not expand word: %q", expanded) + } +} diff --git a/internal/braille/translate_unavailable.go b/internal/braille/translate_unavailable.go new file mode 100644 index 0000000..e8ca815 --- /dev/null +++ b/internal/braille/translate_unavailable.go @@ -0,0 +1,9 @@ +//go:build !brlapi || !cgo + +package braille + +import "errors" + +func translate(string, string, int, bool) (string, []int, int, error) { + return "", nil, 0, errors.New("contracted braille requires a helper built with -tags brlapi and liblouis") +} diff --git a/internal/braille/unavailable.go b/internal/braille/unavailable.go new file mode 100644 index 0000000..6d601d3 --- /dev/null +++ b/internal/braille/unavailable.go @@ -0,0 +1,12 @@ +//go:build !brlapi || !cgo + +package braille + +type unavailable struct{} + +func New() Backend { return unavailable{} } +func NewRouting() Backend { return unavailable{} } +func (unavailable) Open() (Geometry, error) { return Geometry{}, ErrUnavailable } +func (unavailable) Write(string, int) error { return ErrUnavailable } +func (unavailable) ReadKey() (Key, error) { return NoKey, ErrUnavailable } +func (unavailable) Close() error { return nil } diff --git a/internal/braille/unavailable_test.go b/internal/braille/unavailable_test.go new file mode 100644 index 0000000..8ac85ef --- /dev/null +++ b/internal/braille/unavailable_test.go @@ -0,0 +1,21 @@ +//go:build !brlapi || !cgo + +package braille + +import ( + "bytes" + "context" + "errors" + "testing" +) + +func TestDiagnosticUnavailable(t *testing.T) { + var output bytes.Buffer + err := Diagnostic(context.Background(), New(), &output) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("error: %v", err) + } + if output.Len() != 0 { + t.Fatal("unavailable backend reported connected") + } +} diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..65d7901 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package clipboard + +import ( + "encoding/json" + "io" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// Snapshot contains only the clipboard facts needed for Cthulhu's announcement. +type Snapshot struct { + Text string `json:"text"` + HasText bool `json:"hasText"` + Image bool `json:"image"` + URIs []string `json:"uris"` +} + +// WriteMessage is called only by the short lived clipboard helper process. +// URI filesystem checks stay here so the parent can enforce a read deadline. +func WriteMessage(output io.Writer) error { + snapshot, err := read_native() + if err != nil { + return err + } + return json.NewEncoder(output).Encode(struct { + Message string `json:"message"` + }{Message: Format(snapshot)}) +} + +func Format(snapshot Snapshot) string { + if snapshot.Image { + return "The clipboard contains a image" + } + if len(snapshot.URIs) != 0 { + return format_uris(snapshot.URIs) + } + if snapshot.HasText { + return "Text in clipboard " + snapshot.Text + } + return "The clipboard is empty" +} + +func format_uris(uris []string) string { + objects := make([]string, 0, len(uris)) + for _, raw := range uris { + if raw == "" { + continue + } + parsed, err := url.Parse(raw) + if err != nil { + objects = append(objects, raw) + continue + } + name := path.Base(parsed.Path) + if name == "." || name == "/" { + name = raw + } + parts := []string{name} + if parsed.Scheme == "file" && (parsed.Host == "" || parsed.Host == "localhost") { + localPath := parsed.Path + link := false + if info, err := os.Lstat(localPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + link = true + } + if info, err := os.Stat(localPath); err == nil { + if info.IsDir() { + parts = append(parts, "Folder") + } else if info.Mode().IsRegular() { + parts = append(parts, "File") + } + if !link && is_mount(localPath, info) { + parts = append(parts, "Disk") + } + } + if link { + parts = append(parts, "Link") + } + } + objects = append(objects, strings.Join(parts, " ")) + } + if len(objects) == 0 { + return "The clipboard is empty" + } + label := " Object in clipboard " + if len(objects) > 1 { + label = " Objects in clipboard " + } + return strconv.Itoa(len(objects)) + label + strings.Join(objects, " ") +} + +func is_mount(localPath string, info os.FileInfo) bool { + parentPath := filepath.Dir(filepath.Clean(localPath)) + parent, err := os.Stat(parentPath) + if err != nil { + return false + } + currentStat, currentOK := info.Sys().(*syscall.Stat_t) + parentStat, parentOK := parent.Sys().(*syscall.Stat_t) + return currentOK && parentOK && (currentStat.Dev != parentStat.Dev || currentStat.Ino == parentStat.Ino) +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 0000000..5bc1f79 --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package clipboard + +import ( + "net/url" + "os" + "path/filepath" + "testing" +) + +func TestFormatClipboardContent(t *testing.T) { + for _, test := range []struct { + name string + snapshot Snapshot + want string + }{ + {"empty", Snapshot{}, "The clipboard is empty"}, + {"text", Snapshot{HasText: true, Text: "hello"}, "Text in clipboard hello"}, + {"empty text", Snapshot{HasText: true}, "Text in clipboard "}, + {"image overrides text", Snapshot{HasText: true, Text: "hello", Image: true}, "The clipboard contains a image"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := Format(test.snapshot); got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} + +func TestFormatURIObjects(t *testing.T) { + root := t.TempDir() + folder := filepath.Join(root, "a folder") + file := filepath.Join(root, "a file") + link := filepath.Join(root, "link") + if err := os.Mkdir(folder, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(file, link); err != nil { + t.Fatal(err) + } + uri := func(localPath string) string { return (&url.URL{Scheme: "file", Path: localPath}).String() } + snapshot := Snapshot{URIs: []string{uri(folder), uri(file), uri(link)}} + want := "3 Objects in clipboard a folder Folder a file File link File Link" + if got := Format(snapshot); got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := Format(Snapshot{URIs: []string{"", uri(file)}}); got != "1 Object in clipboard a file File" { + t.Fatalf("single URI = %q", got) + } +} diff --git a/internal/clipboard/native.go b/internal/clipboard/native.go new file mode 100644 index 0000000..816a4db --- /dev/null +++ b/internal/clipboard/native.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package clipboard + +/* +#cgo pkg-config: gtk+-3.0 +#include + +typedef struct { + char *text; + char **uris; + int image; + int initialized; +} ClipboardSnapshot; + +static ClipboardSnapshot read_clipboard(void) { + ClipboardSnapshot result = {0}; + if (!gtk_init_check(NULL, NULL)) return result; + result.initialized = 1; + GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (!clipboard) { result.initialized = 0; return result; } + result.text = gtk_clipboard_wait_for_text(clipboard); + GdkPixbuf *image = gtk_clipboard_wait_for_image(clipboard); + if (image) { + result.image = 1; + g_object_unref(image); + } + if (gtk_clipboard_wait_is_uris_available(clipboard)) { + result.uris = gtk_clipboard_wait_for_uris(clipboard); + } + return result; +} + +static void free_clipboard(ClipboardSnapshot *snapshot) { + g_free(snapshot->text); + g_strfreev(snapshot->uris); +} +*/ +import "C" + +import ( + "errors" + "unsafe" +) + +func read_native() (Snapshot, error) { + native := C.read_clipboard() + defer C.free_clipboard(&native) + if native.initialized == 0 { + return Snapshot{}, errors.New("cannot access the desktop clipboard") + } + snapshot := Snapshot{Image: native.image != 0, HasText: native.text != nil} + if native.text != nil { + snapshot.Text = C.GoString(native.text) + } + if native.uris != nil { + for offset := uintptr(0); ; offset += unsafe.Sizeof(uintptr(0)) { + uri := *(**C.char)(unsafe.Add(unsafe.Pointer(native.uris), offset)) + if uri == nil { + break + } + snapshot.URIs = append(snapshot.URIs, C.GoString(uri)) + } + } + return snapshot, nil +} diff --git a/internal/clipboard/watch.c b/internal/clipboard/watch.c new file mode 100644 index 0000000..eb499a6 --- /dev/null +++ b/internal/clipboard/watch.c @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "watch.h" +static GQueue changes = G_QUEUE_INIT; +static gboolean watchEnabled; +static unsigned long long watchEpoch; + +void clipboard_watch_free(ClipboardChange *change) { + if (--change->references) return; + g_free(change->text); + g_free(change); +} + +static void text_received(GtkClipboard *clipboard, const gchar *text, gpointer data) { + (void)clipboard; + ClipboardChange *change = data; + change->text = g_strdup(text); + change->done = TRUE; + clipboard_watch_free(change); +} + +static void targets_received(GtkClipboard *clipboard, GdkAtom *targets, gint count, gpointer data) { + ClipboardChange *change = data; + if (!watchEnabled || watchEpoch != change->epoch || !targets || + gtk_targets_include_image(targets, count, FALSE) || + gtk_targets_include_uri(targets, count) || + !gtk_targets_include_text(targets, count)) { + change->done = TRUE; + clipboard_watch_free(change); + return; + } + // No image or file payload is requested, only a text target. + gtk_clipboard_request_text(clipboard, text_received, change); +} + +static void owner_changed(GtkClipboard *clipboard, GdkEventOwnerChange *event, gpointer data) { + (void)data; + if (!watchEnabled) return; + if (g_queue_get_length(&changes) >= 128) + clipboard_watch_free(g_queue_pop_head(&changes)); + ClipboardChange *change = g_new0(ClipboardChange, 1); + change->epoch = watchEpoch; + change->timestamp = event->time; + change->deadline = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; + change->references = 2; // queue and asynchronous callback + g_queue_push_tail(&changes, change); + gtk_clipboard_request_targets(clipboard, targets_received, change); +} + +int clipboard_watch_init(void) { + if (!gtk_init_check(NULL, NULL)) return 0; + GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (!clipboard) return 0; + g_signal_connect(clipboard, "owner-change", G_CALLBACK(owner_changed), NULL); + return 1; +} + +void clipboard_watch_enable(int enabled, unsigned long long epoch) { + watchEnabled = enabled; + watchEpoch = epoch; +} +void clipboard_watch_iterate(void) { + while (gtk_events_pending()) gtk_main_iteration_do(FALSE); +} +ClipboardChange *clipboard_watch_next(void) { + ClipboardChange *change = g_queue_peek_head(&changes); + if (!change || (!change->done && g_get_monotonic_time() < change->deadline)) return NULL; + return g_queue_pop_head(&changes); +} diff --git a/internal/clipboard/watch.go b/internal/clipboard/watch.go new file mode 100644 index 0000000..902a4ad --- /dev/null +++ b/internal/clipboard/watch.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package clipboard + +/* +#include "watch.h" +*/ +import "C" + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "runtime" + "time" +) + +type WatchSettings struct { + Enabled bool + Epoch uint64 +} + +type TextChange struct { + Text string + Epoch uint64 + Timestamp uint32 + Ready bool +} + +// WatchText keeps a GTK selection connection warm so successive clipboard +// changes are captured asynchronously and emitted in arrival order. Disabled +// applications never request clipboard targets or contents. +func WatchText(ctx context.Context, input io.Reader, output io.Writer) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + settings := make(chan WatchSettings, 1) + inputDone := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 1024), 4096) + for scanner.Scan() { + var request WatchSettings + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { + inputDone <- err + return + } + select { + case settings <- request: + case <-ctx.Done(): + return + } + } + inputDone <- scanner.Err() + }() + if C.clipboard_watch_init() == 0 { + return errors.New("cannot watch clipboard on this display") + } + encoder := json.NewEncoder(output) + if err := encoder.Encode(TextChange{Ready: true}); err != nil { + return err + } + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-inputDone: + return err + case request := <-settings: + enabled := C.int(0) + if request.Enabled { + enabled = 1 + } + C.clipboard_watch_enable(enabled, C.ulonglong(request.Epoch)) + if err := encoder.Encode(TextChange{Ready: true, Epoch: request.Epoch}); err != nil { + return err + } + case <-ticker.C: + C.clipboard_watch_iterate() + for { + change := C.clipboard_watch_next() + if change == nil { + break + } + value := TextChange{Text: C.GoString(change.text), Epoch: uint64(change.epoch), Timestamp: uint32(change.timestamp)} + C.clipboard_watch_free(change) + if err := encoder.Encode(value); err != nil { + return err + } + } + } + } +} diff --git a/internal/clipboard/watch.h b/internal/clipboard/watch.h new file mode 100644 index 0000000..8a9bccb --- /dev/null +++ b/internal/clipboard/watch.h @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include +typedef struct { + char *text; + unsigned long long epoch; + gboolean done; + gint64 deadline; + guint timestamp; + int references; +} ClipboardChange; +int clipboard_watch_init(void); +void clipboard_watch_enable(int enabled, unsigned long long epoch); +void clipboard_watch_iterate(void); +ClipboardChange *clipboard_watch_next(void); +void clipboard_watch_free(ClipboardChange *change); diff --git a/internal/config/application_store_test.go b/internal/config/application_store_test.go new file mode 100644 index 0000000..efce51a --- /dev/null +++ b/internal/config/application_store_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestApplicationSettingsPersistWithoutChangingGlobalProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + global := DefaultSettings() + global.TimeFormat = "24-hour" + if err := store.Save(global); err != nil { + t.Fatal(err) + } + application := global + application.TimeFormat = "12-hour" + if err := store.SaveForApplication("Example Editor", application); err != nil { + t.Fatal(err) + } + other := global + other.DateFormat = "numbers-ymd" + if err := store.SaveForApplication("Other Editor", other); err != nil { + t.Fatal(err) + } + reopened, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + loaded, err := reopened.Load() + if err != nil || loaded.TimeFormat != "24-hour" || loaded.ActiveProfile != "default" { + t.Fatalf("global = %+v, %v", loaded, err) + } + appLoaded, found, err := reopened.LoadForApplication("Example Editor") + if err != nil || !found || appLoaded.TimeFormat != "12-hour" { + t.Fatalf("application = %+v, found %t, %v", appLoaded, found, err) + } + missing, found, err := reopened.LoadForApplication("Unknown Editor") + if err != nil || found || missing.TimeFormat != "24-hour" { + t.Fatalf("unmapped = %+v, found %t, %v", missing, found, err) + } + global.DateFormat = "numbers-mdy" + if err := reopened.Save(global); err != nil { + t.Fatal(err) + } + appLoaded, found, err = reopened.LoadForApplication("Example Editor") + if err != nil || !found || appLoaded.TimeFormat != "12-hour" || appLoaded.DateFormat != "numbers-mdy" { + t.Fatalf("application after global save = %+v, found %t, %v", appLoaded, found, err) + } +} diff --git a/internal/config/bindings_test.go b/internal/config/bindings_test.go new file mode 100644 index 0000000..6e9ce8f --- /dev/null +++ b/internal/config/bindings_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package config + +import ( + "git.stormux.org/storm/cthulhu/internal/input" + "path/filepath" + "reflect" + "testing" +) + +func TestKeyBindingsSaveLoadAndIsolation(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) + if err != nil { + t.Fatal(err) + } + settings := DefaultSettings() + settings.KeyBindings = []input.BindingOverride{ + {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"Cthulhu+Ctrl+F8"}}, + {Layout: input.LaptopKeyboardLayout, Scope: "browse", Command: input.DocumentNextHeading, Keys: []string{}}, + } + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil || !reflect.DeepEqual(settings.KeyBindings, loaded.KeyBindings) { + t.Fatalf("round trip: %+v %v", loaded.KeyBindings, err) + } + loaded.KeyBindings[0].Keys[0] = "F1" + if store.Current().KeyBindings[0].Keys[0] == "F1" { + t.Fatal("caller changed store bindings") + } + settings.KeyBindings = nil + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + loaded, err = store.Load() + if err != nil || len(loaded.KeyBindings) != 0 { + t.Fatalf("reset failed: %+v %v", loaded.KeyBindings, err) + } +} diff --git a/internal/config/clipboard_test.go b/internal/config/clipboard_test.go new file mode 100644 index 0000000..7d1d218 --- /dev/null +++ b/internal/config/clipboard_test.go @@ -0,0 +1,16 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestClipboardReadingDefaultsOffForExistingSettings(t *testing.T) { + settings, err := Load(filepath.Join("testdata", "settings-v2.toml")) + if err != nil { + t.Fatal(err) + } + if settings.Reading.AutomaticallyReadClipboardChanges { + t.Fatal("omitted clipboard reading preference defaults on") + } +} diff --git a/internal/config/codec.go b/internal/config/codec.go new file mode 100644 index 0000000..7b4c044 --- /dev/null +++ b/internal/config/codec.go @@ -0,0 +1,145 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "bytes" + "errors" + "fmt" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +func defaultDocument() writableDocument { + return writableDocument{ + SchemaVersion: CurrentSchemaVersion, + ActiveProfile: "default", + Profiles: map[string]writableProfile{ + "default": profileFromSettings(DefaultSettings()), + }, + Applications: make(map[string]string), + } +} + +func decodeWritable(contents []byte) (Settings, error) { + _, settings, err := decodeDocument(contents) + return settings, err +} + +func decodeDocument(contents []byte) (writableDocument, Settings, error) { + var envelope struct { + SchemaVersion int `toml:"schema-version"` + } + if err := toml.Unmarshal(contents, &envelope); err != nil { + return writableDocument{}, Settings{}, fmt.Errorf("parse settings: %w", err) + } + if envelope.SchemaVersion != CurrentSchemaVersion { + return writableDocument{}, Settings{}, fmt.Errorf("unsupported settings schema version %d; expected %d", envelope.SchemaVersion, CurrentSchemaVersion) + } + var document writableDocument + decoder := toml.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&document); err != nil { + var strictError *toml.StrictMissingError + if errors.As(err, &strictError) { + return writableDocument{}, Settings{}, fmt.Errorf("parse settings schema %d: %s", CurrentSchemaVersion, strictError.String()) + } + return writableDocument{}, Settings{}, fmt.Errorf("parse settings schema %d: %w", CurrentSchemaVersion, err) + } + settings, err := effectiveSettings(document) + if err != nil { + return writableDocument{}, Settings{}, err + } + return document, settings, nil +} + +func effectiveSettings(document writableDocument) (Settings, error) { + if strings.TrimSpace(document.ActiveProfile) == "" { + return Settings{}, errors.New("validate configuration: active-profile is empty") + } + profile, exists := document.Profiles[document.ActiveProfile] + if !exists { + return Settings{}, fmt.Errorf("validate configuration: active profile %q does not exist", document.ActiveProfile) + } + for _, name := range sortedKeys(document.Profiles) { + candidate := document.Profiles[name] + if strings.TrimSpace(name) != name || name == "" { + return Settings{}, fmt.Errorf("validate configuration: invalid profile name %q", name) + } + if err := validateCommandValue("profile name", name); err != nil { + return Settings{}, err + } + candidateSettings := DefaultSettings() + applyProfile(&candidateSettings, candidate) + if err := validate(candidateSettings); err != nil { + return Settings{}, fmt.Errorf("profile %q: %w", name, err) + } + } + for _, application := range sortedKeys(document.Applications) { + profileName := document.Applications[application] + if strings.TrimSpace(application) != application || application == "" { + return Settings{}, fmt.Errorf("validate configuration: invalid application identifier %q", application) + } + if err := validateCommandValue("application identifier", application); err != nil { + return Settings{}, err + } + if _, exists := document.Profiles[profileName]; !exists { + return Settings{}, fmt.Errorf("validate configuration: application %q selects missing profile %q", application, profileName) + } + } + settings := DefaultSettings() + applyProfile(&settings, profile) + settings.ActiveProfile = document.ActiveProfile + return settings, nil +} + +func sortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func encodeWritable(settings Settings) ([]byte, error) { + document := defaultDocument() + profileName := settings.ActiveProfile + if profileName == "" { + profileName = document.ActiveProfile + } + document.ActiveProfile = profileName + document.Profiles = map[string]writableProfile{profileName: profileFromSettings(settings)} + return encodeDocument(document) +} + +func encodeDocument(document writableDocument) ([]byte, error) { + if document.SchemaVersion != CurrentSchemaVersion { + return nil, fmt.Errorf("encode settings schema version %d; expected %d", document.SchemaVersion, CurrentSchemaVersion) + } + if _, err := effectiveSettings(document); err != nil { + return nil, err + } + contents, err := toml.Marshal(document) + if err != nil { + return nil, fmt.Errorf("encode settings: %w", err) + } + return contents, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b3f75bf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,109 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" +) + +const maxConfigSize = 1024 * 1024 + +func DefaultPath() (string, error) { + if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" { + return filepath.Join(dataHome, "cthulhu", "user-settings-go.toml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".local", "share", "cthulhu", "user-settings-go.toml"), nil +} + +// Load reads legacy Python settings or the current versioned schema without +// modifying the file. A present schema marker always selects strict decoding. +func Load(path string) (Settings, error) { + settings := DefaultSettings() + if path == "" { + var err error + path, err = DefaultPath() + if err != nil { + return Settings{}, err + } + } + settings.Path = path + + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return settings, nil + } + if err != nil { + return Settings{}, fmt.Errorf("open configuration: %w", err) + } + defer file.Close() + + contents, err := io.ReadAll(io.LimitReader(file, maxConfigSize+1)) + if err != nil { + return Settings{}, fmt.Errorf("read configuration: %w", err) + } + if len(contents) > maxConfigSize { + return Settings{}, fmt.Errorf("configuration exceeds %d bytes", maxConfigSize) + } + + var envelope struct { + SchemaVersion *int `toml:"schema-version"` + } + if err := toml.Unmarshal(contents, &envelope); err != nil { + return Settings{}, fmt.Errorf("parse configuration: %w", err) + } + if envelope.SchemaVersion != nil { + settings, err := decodeWritable(contents) + if err != nil { + return Settings{}, err + } + settings.Path = path + settings.Loaded = true + return settings, nil + } + + var parsed document + if err := toml.Unmarshal(contents, &parsed); err != nil { + return Settings{}, fmt.Errorf("parse configuration: %w", err) + } + apply(&settings, parsed.General) + + profileName := selectedProfile(parsed.General, parsed.Profiles) + if profileName != "" { + profile, exists := parsed.Profiles[profileName] + if !exists { + return Settings{}, fmt.Errorf("configured profile %q does not exist", profileName) + } + apply(&settings, profile) + } + settings.ActiveProfile = profileName + settings.Loaded = true + if err := validate(settings); err != nil { + return Settings{}, err + } + return settings, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2765559 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,284 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestLoadVersionedSettingsMatchesStore(t *testing.T) { + path := filepath.Join("testdata", "settings-v2.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + want, err := store.Load() + if err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("runtime settings = %#v, want store settings %#v", got, want) + } +} + +func TestLegacySayAllStyleMapsToRuntimeChoice(t *testing.T) { + for legacy, want := range map[string]string{"0": "line", "1": "sentence"} { + settings, err := Load(writeConfig(t, "[general]\nsayAllStyle = "+legacy+"\n")) + if err != nil { + t.Fatal(err) + } + if settings.SayAllStyle != want { + t.Fatalf("legacy Say All style %s loaded as %q", legacy, settings.SayAllStyle) + } + } +} + +func TestLoadRejectsInvalidVersionedSettingsWithoutWriting(t *testing.T) { + for name, contents := range map[string]string{ + "old": "schema-version = 1\n[general]\n", + "future": "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n", + "zero": "schema-version = 0\n[general]\n", + "negative": "schema-version = -1\n[general]\n", + "wrong type": "schema-version = '1'\n[general]\n", + "unknown field": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\nfuture-setting = true\n", + "legacy field": "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\nenableSpeech = false\n", + "invalid value": "schema-version = 2\nactive-profile = 'default'\n[profiles.default.sound]\nvolume = 2.0\n", + "malformed": "schema-version = 2\n[profiles\n", + } { + t.Run(name, func(t *testing.T) { + path := writeConfig(t, contents) + if _, err := Load(path); err == nil { + t.Fatal("Load accepted invalid versioned settings") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != contents { + t.Fatal("Load changed the settings file") + } + }) + } +} + +func TestLoadMergesGeneralAndStartingProfile(t *testing.T) { + path := writeConfig(t, ` +[general] +enableSpeech = true +speechServerFactory = "speechdispatcherfactory" +speakDescription = true +speechVerbosityLevel = 1 +verbalizePunctuationStyle = 2 +capitalizationStyle = "spell" +keyboardLayout = 1 +cthulhuModifierKeys = ["Insert", "KP_Insert"] +enableKeyEcho = true +enableEchoByCharacter = false +enableEchoByWord = true +enableEchoBySentence = false +enableAlphabeticKeys = true +enableNumericKeys = true +enablePunctuationKeys = false +enableSpace = true +enableModifierKeys = true +enableFunctionKeys = true +enableActionKeys = true +enableNavigationKeys = false +enableDiacriticalKeys = false +startingProfile = ["Quiet", "quiet"] +unknownFutureSetting = true + +[general.voices.default] +rate = 40.0 +average-pitch = 5.0 +gain = 8.0 + +[profiles.quiet] +speakDescription = false +enableKeyEcho = false +enableEchoByCharacter = true +enableEchoByWord = false +enableEchoBySentence = true +enableAlphabeticKeys = false +speechServerFactory = "cthulhu.speechdispatcherfactory" + +[profiles.quiet.voices.default] +rate = 75.0 + +[profiles.quiet.voices.default.family] +name = "Generic Voice" +lang = "en" +dialect = "US" +`) + + settings, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !settings.Loaded || settings.ActiveProfile != "quiet" { + t.Fatalf("loaded/profile = %v/%q, want true/quiet", settings.Loaded, settings.ActiveProfile) + } + if settings.SpeakDescription || settings.EnableKeyEcho { + t.Fatal("profile boolean overrides were not applied") + } + if !settings.EnableEchoByCharacter || settings.EnableEchoByWord || !settings.EnableEchoBySentence || + settings.EnableAlphabeticKeys || settings.EnablePunctuationKeys || + !settings.EnableNumericKeys || !settings.EnableSpace || !settings.EnableModifierKeys || + !settings.EnableFunctionKeys || !settings.EnableActionKeys || settings.EnableNavigationKeys || settings.EnableDiacriticalKeys { + t.Fatalf("typing echo settings = %+v", settings) + } + if settings.SpeechServerFactory != "cthulhu.speechdispatcherfactory" { + t.Fatalf("speech server factory = %q", settings.SpeechServerFactory) + } + if settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 75 { + t.Fatalf("voice rate = %v, want 75", settings.DefaultVoice.Rate) + } + if settings.DefaultVoice.Gain == nil || *settings.DefaultVoice.Gain != 8 { + t.Fatalf("voice gain = %v, want inherited value 8", settings.DefaultVoice.Gain) + } + if settings.DefaultVoice.Family == nil || settings.DefaultVoice.Family.Name != "Generic Voice" { + t.Fatalf("voice family = %#v", settings.DefaultVoice.Family) + } +} + +func TestLoadMissingFileUsesDefaults(t *testing.T) { + settings, err := Load(filepath.Join(t.TempDir(), "missing.toml")) + if err != nil { + t.Fatalf("Load: %v", err) + } + if settings.Loaded { + t.Fatal("missing file reported as loaded") + } + if !settings.EnableSpeech || !settings.SpeakDescription || !settings.Reading.EnableMnemonicSpeaking || settings.KeyboardLayout != 1 { + t.Fatalf("unexpected defaults: %#v", settings) + } +} + +func TestDefaultPathUsesGoDevelopmentFile(t *testing.T) { + dataHome := filepath.Join(t.TempDir(), "data") + t.Setenv("XDG_DATA_HOME", dataHome) + path, err := DefaultPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dataHome, "cthulhu", "user-settings-go.toml"); path != want { + t.Fatalf("DefaultPath = %q, want %q", path, want) + } +} + +func TestDefaultLoadDoesNotFallBackToPythonSettings(t *testing.T) { + dataHome := filepath.Join(t.TempDir(), "data") + t.Setenv("XDG_DATA_HOME", dataHome) + directory := filepath.Join(dataHome, "cthulhu") + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "user-settings.toml"), []byte("[general]\nenableSpeech = false\n"), 0o600); err != nil { + t.Fatal(err) + } + settings, err := Load("") + if err != nil { + t.Fatal(err) + } + if settings.Loaded || !settings.EnableSpeech || filepath.Base(settings.Path) != "user-settings-go.toml" { + t.Fatalf("default settings = %#v", settings) + } +} + +func TestLoadRejectsInvalidSettings(t *testing.T) { + path := writeConfig(t, "[general]\nkeyboardLayout = 9\n") + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "keyboardLayout") { + t.Fatalf("Load error = %v, want keyboardLayout validation error", err) + } +} + +func TestLoadRejectsOversizedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "user-settings.toml") + if err := os.WriteFile(path, []byte(strings.Repeat("#", maxConfigSize+1)), 0o600); err != nil { + t.Fatalf("write oversized config: %v", err) + } + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("Load error = %v, want size error", err) + } +} + +func writeConfig(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "user-settings.toml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestSoundSettingsMergeAndValidate(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + if err := os.WriteFile(path, []byte(`[general] +soundVolume = 0.4 +soundTheme = "default" +[profiles.default] +enableSound = true +soundVolume = 0.7 +soundSink = "pulse" +playSoundForRole = true +playSoundForState = true +roleSoundPresentation = "sound_only" +`), 0o600); err != nil { + t.Fatal(err) + } + settings, err := Load(path) + if err != nil { + t.Fatal(err) + } + if settings.SoundVolume != 0.7 || !settings.PlaySoundForRole || !settings.PlaySoundForState || settings.RoleSoundPresentation != "sound_only" { + t.Fatalf("sound settings = %+v", settings) + } + if err := os.WriteFile(path, []byte("[general]\nroleSoundPresentation = \"speech_only\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err != nil { + t.Fatalf("speech-only role presentation was rejected: %v", err) + } + for _, line := range []string{`soundVolume = -0.1`, `soundVolume = 1.1`, `soundVolume = nan`, `soundTheme = "../other"`, `soundSink = "invalid"`, `roleSoundPresentation = "invalid"`} { + if err := os.WriteFile(path, []byte("[general]\n"+line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatalf("accepted %s", line) + } + } +} + +func TestLoadRejectsNonFiniteVoiceValues(t *testing.T) { + for _, value := range []string{"nan", "+inf", "-inf"} { + path := writeConfig(t, "[general.voices.default]\nrate = "+value+"\n") + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "default voice rate") { + t.Fatalf("Load rate %s error = %v", value, err) + } + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..127a05b --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1,89 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +func DefaultSettings() Settings { + return Settings{ + OCR: OCRSettings{Layout: 3, Scale: 1, Threshold: 200, RefreshInterval: 1500, ConfirmClick: true}, + TimeFormat: "locale", DateFormat: "locale", + EnableSound: true, SoundVolume: 0.5, SoundSink: "auto", SoundTheme: "default", + PlaySoundForRole: true, PlaySoundForState: true, PlaySoundForPositionInSet: true, + ProgressBar: DefaultProgressBarSettings(), + RoleSoundPresentation: "sound_and_speech", + EnableSpeech: true, + SpeechHistoryLimit: DefaultSpeechHistoryLimit, + Braille: BrailleOptions{Verbosity: 1, SelectionIndicator: 0xc0, LinkIndicator: 0xc0, + FlashMessages: true, FlashTime: 5000, FlashDetailed: true}, + SpeechServerFactory: "speechdispatcherfactory", + HardwareSpeechBaudRate: 9600, + SpeakDescription: true, + SpeechVerbosityLevel: 1, + SayAllStyle: "line", + TextAttributes: DefaultTextAttributes(), + Reading: ReadingSettings{ + AutomaticallyReadTitleChanges: true, + EnablePauseBreaks: true, + SpeakNumbersAsDigits: false, + SpeakMultiCaseStringsAsWords: false, + EnableIndentation: false, + IndentationPresentationMode: "speech", + OnlySpeakDisplayedText: false, + SpeakBlankLines: true, + EnableMnemonicSpeaking: true, + EnablePositionSpeaking: false, + EnableTutorialMessages: false, + MessagesAreDetailed: true, + UseColorNames: true, + SpeakMisspelledIndicator: true, + SpeakContextBlockquote: true, + SpeakContextList: true, + SpeakContextTable: true, + SpeakContextPanel: true, + SpeakContextLandmark: true, + SpeakContextNonLandmarkForm: true, + SayAllContextBlockquote: true, + SayAllContextList: true, + SayAllContextTable: true, + SayAllContextPanel: true, + SayAllContextLandmark: true, + SayAllContextNonLandmarkForm: true, + ReadFullRowInGUITable: true, + ReadFullRowInDocumentTable: true, + ReadFullRowInSpreadSheet: false, + }, + VerbalizePunctuationStyle: 1, + CapitalizationStyle: "none", + KeyboardLayout: 1, + CthulhuModifierKeys: []string{"Insert", "KP_Insert"}, + UseCustomEchoForKey: true, + UseCustomEchoForCharacter: true, + EnableKeyEcho: true, + EnableEchoByCharacter: false, + EnableEchoByWord: false, + EnableEchoBySentence: false, + EnableAlphabeticKeys: true, + EnableNumericKeys: true, + EnablePunctuationKeys: true, + EnableSpace: true, + EnableModifierKeys: true, + EnableFunctionKeys: true, + EnableActionKeys: true, + EnableNavigationKeys: false, + EnableDiacriticalKeys: false, + } +} diff --git a/internal/config/hardware_test.go b/internal/config/hardware_test.go new file mode 100644 index 0000000..357489a --- /dev/null +++ b/internal/config/hardware_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "path/filepath" + "testing" +) + +func TestHardwareSettingsPersistInApplicationProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + global := DefaultSettings() + if err := store.Save(global); err != nil { + t.Fatal(err) + } + application := global + application.SpeechServerFactory = "hardwarefactory" + application.SpeechOutputModule = "dectalk" + application.HardwareSpeechDevice = "/dev/ttyTEST0" + application.HardwareSpeechBaudRate = 19200 + if err := store.SaveForApplication("Example Editor", application); err != nil { + t.Fatal(err) + } + reopened, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + loaded, found, err := reopened.LoadForApplication("Example Editor") + if err != nil || !found || loaded.SpeechServerFactory != "hardwarefactory" || + loaded.SpeechOutputModule != "dectalk" || loaded.HardwareSpeechDevice != "/dev/ttyTEST0" || loaded.HardwareSpeechBaudRate != 19200 { + t.Fatalf("hardware profile=%+v found=%v error=%v", loaded, found, err) + } + loaded, err = reopened.Load() + if err != nil || loaded.SpeechServerFactory != "speechdispatcherfactory" || loaded.HardwareSpeechDevice != "" || loaded.HardwareSpeechBaudRate != 9600 { + t.Fatalf("hardware override changed global settings=%+v error=%v", loaded, err) + } +} + +func TestHardwareSettingsLoadPythonAndRejectInvalidDevices(t *testing.T) { + loaded, err := Load(writeConfig(t, `[general] +speechServerFactory = "hardwarefactory" +speechServerInfo = ["LiteTalk", "litetalk"] +hardwareSpeechDevice = "/dev/ttyTEST0" +hardwareSpeechBaudRate = 9600 +`)) + if err != nil || loaded.SpeechOutputModule != "litetalk" || loaded.HardwareSpeechDevice != "/dev/ttyTEST0" { + t.Fatalf("Python hardware settings=%+v error=%v", loaded, err) + } + for _, device := range []string{"", "auto", "ttyTEST0", "/dev/ttyTEST0\n"} { + loaded.HardwareSpeechDevice = device + if err := Validate(loaded); err == nil { + t.Fatalf("accepted device %q", device) + } + } +} diff --git a/internal/config/legacy.go b/internal/config/legacy.go new file mode 100644 index 0000000..f2405fa --- /dev/null +++ b/internal/config/legacy.go @@ -0,0 +1,140 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "strings" +) + +type rawReadingSettings struct { + EnablePauseBreaks *bool `toml:"enablePauseBreaks"` + SpeakNumbersAsDigits *bool `toml:"speakNumbersAsDigits"` + SpeakMultiCaseStringsAsWords *bool `toml:"speakMultiCaseStringsAsWords"` + EnableIndentation *bool `toml:"enableIndentation"` + OnlySpeakDisplayedText *bool `toml:"onlySpeakDisplayedText"` + SpeakBlankLines *bool `toml:"speakBlankLines"` + EnableMnemonicSpeaking *bool `toml:"enableMnemonicSpeaking"` + EnablePositionSpeaking *bool `toml:"enablePositionSpeaking"` + EnableTutorialMessages *bool `toml:"enableTutorialMessages"` + MessagesAreDetailed *bool `toml:"messagesAreDetailed"` + UseColorNames *bool `toml:"useColorNames"` + PresentTooltips *bool `toml:"presentToolTips"` + EnableMouseReview *bool `toml:"enableMouseReview"` + SpeakMisspelledIndicator *bool `toml:"speakMisspelledIndicator"` + SpeakContextBlockquote *bool `toml:"speakContextBlockquote"` + SpeakContextList *bool `toml:"speakContextList"` + SpeakContextTable *bool `toml:"speakContextTable"` + SpeakContextPanel *bool `toml:"speakContextPanel"` + SpeakContextLandmark *bool `toml:"speakContextLandmark"` + SpeakContextNonLandmarkForm *bool `toml:"speakContextNonLandmarkForm"` + SayAllContextBlockquote *bool `toml:"sayAllContextBlockquote"` + SayAllContextList *bool `toml:"sayAllContextList"` + SayAllContextTable *bool `toml:"sayAllContextTable"` + SayAllContextPanel *bool `toml:"sayAllContextPanel"` + SayAllContextLandmark *bool `toml:"sayAllContextLandmark"` + SayAllContextNonLandmarkForm *bool `toml:"sayAllContextNonLandmarkForm"` + ReadFullRowInGUITable *bool `toml:"readFullRowInGUITable"` + ReadFullRowInDocumentTable *bool `toml:"readFullRowInDocumentTable"` + ReadFullRowInSpreadSheet *bool `toml:"readFullRowInSpreadSheet"` +} + +type rawSettings struct { + TimeFormat *string `toml:"time-format"` + DateFormat *string `toml:"date-format"` + UseCustomEchoVoice *bool `toml:"useCustomEchoVoice"` + UseCustomEchoSpeechServer *bool `toml:"useCustomEchoSpeechServer"` + UseCustomEchoForKey *bool `toml:"useCustomEchoForKey"` + UseCustomEchoForCharacter *bool `toml:"useCustomEchoForCharacter"` + UseCustomEchoForWord *bool `toml:"useCustomEchoForWord"` + UseCustomEchoForSentence *bool `toml:"useCustomEchoForSentence"` + EchoVoice *Voice `toml:"echoVoice"` + EchoSpeechServerInfo []string `toml:"echoSpeechServerInfo"` + + EnableSound *bool `toml:"enableSound"` + SoundVolume *float64 `toml:"soundVolume"` + SoundSink *string `toml:"soundSink"` + SoundTheme *string `toml:"soundTheme"` + PlaySoundForRole *bool `toml:"playSoundForRole"` + PlaySoundForState *bool `toml:"playSoundForState"` + PlaySoundForPositionInSet *bool `toml:"playSoundForPositionInSet"` + PlaySoundForValue *bool `toml:"playSoundForValue"` + SpatializeObjectSounds *bool `toml:"spatializeObjectSounds"` + BeepProgressBarUpdates *bool `toml:"beepProgressBarUpdates"` + ProgressBarBeepInterval *int `toml:"progressBarBeepInterval"` + RoleSoundPresentation *string `toml:"roleSoundPresentation"` + + StartingProfile []string `toml:"startingProfile"` + ActiveProfile []string `toml:"activeProfile"` + EnableSpeech *bool `toml:"enableSpeech"` + EnableBraille *bool `toml:"enableBraille"` + EnableContractedBraille *bool `toml:"enableContractedBraille"` + BrailleContractionTable *string `toml:"brailleContractionTable"` + BrailleRolenameStyle *int `toml:"brailleRolenameStyle"` + BrailleDisableEOL *bool `toml:"disableBrailleEOL"` + BrailleWordWrap *bool `toml:"enableBrailleWordWrap"` + BrailleVerbosity *int `toml:"brailleVerbosityLevel"` + BrailleSelectionIndicator *int `toml:"brailleSelectorIndicator"` + BrailleLinkIndicator *int `toml:"brailleLinkIndicator"` + BrailleFlashMessages *bool `toml:"enableFlashMessages"` + BrailleFlashTime *int `toml:"brailleFlashTime"` + BrailleFlashPersistent *bool `toml:"flashIsPersistent"` + BrailleFlashDetailed *bool `toml:"flashIsDetailed"` + SpeechServerInfo []string `toml:"speechServerInfo"` + HardwareSpeechDevice *string `toml:"hardwareSpeechDevice"` + HardwareSpeechBaudRate *int `toml:"hardwareSpeechBaudRate"` + SpeechServerFactory *string `toml:"speechServerFactory"` + SpeakDescription *bool `toml:"speakDescription"` + SpeechVerbosityLevel *int `toml:"speechVerbosityLevel"` + SayAllStyle *int `toml:"sayAllStyle"` + Reading rawReadingSettings `toml:"reading"` + VerbalizePunctuationStyle *int `toml:"verbalizePunctuationStyle"` + CapitalizationStyle *string `toml:"capitalizationStyle"` + KeyboardLayout *int `toml:"keyboardLayout"` + CthulhuModifierKeys *[]string `toml:"cthulhuModifierKeys"` + EnableKeyEcho *bool `toml:"enableKeyEcho"` + EnableEchoByCharacter *bool `toml:"enableEchoByCharacter"` + EnableEchoByWord *bool `toml:"enableEchoByWord"` + EnableEchoBySentence *bool `toml:"enableEchoBySentence"` + EnableAlphabeticKeys *bool `toml:"enableAlphabeticKeys"` + EnableNumericKeys *bool `toml:"enableNumericKeys"` + EnablePunctuationKeys *bool `toml:"enablePunctuationKeys"` + EnableSpace *bool `toml:"enableSpace"` + EnableModifierKeys *bool `toml:"enableModifierKeys"` + EnableFunctionKeys *bool `toml:"enableFunctionKeys"` + EnableActionKeys *bool `toml:"enableActionKeys"` + EnableNavigationKeys *bool `toml:"enableNavigationKeys"` + EnableDiacriticalKeys *bool `toml:"enableDiacriticalKeys"` + Voices map[string]Voice `toml:"voices"` +} + +type document struct { + General rawSettings `toml:"general"` + Profiles map[string]rawSettings `toml:"profiles"` +} + +func selectedProfile(general rawSettings, profiles map[string]rawSettings) string { + for _, profile := range [][]string{general.StartingProfile, general.ActiveProfile} { + if len(profile) >= 2 && strings.TrimSpace(profile[1]) != "" { + return profile[1] + } + } + if _, exists := profiles["default"]; exists { + return "default" + } + return "" +} diff --git a/internal/config/legacy_mapping.go b/internal/config/legacy_mapping.go new file mode 100644 index 0000000..155dbd3 --- /dev/null +++ b/internal/config/legacy_mapping.go @@ -0,0 +1,335 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "git.stormux.org/storm/cthulhu/internal/datetime" +) + +func apply(settings *Settings, values rawSettings) { + // Keep the established patch order; absent fields leave prior values intact. + apply_legacy_sound(settings, values) + apply_legacy_output(settings, values) + apply_legacy_speech_service(settings, values) + apply_legacy_reading(settings, values) + apply_legacy_speech_formats(settings, values) + apply_legacy_keyboard(settings, values) + apply_legacy_echo(settings, values) + apply_legacy_default_voice(settings, values) +} + +func apply_legacy_sound(settings *Settings, values rawSettings) { + if values.EnableSound != nil { + settings.EnableSound = *values.EnableSound + } + if values.SoundVolume != nil { + settings.SoundVolume = *values.SoundVolume + } + if values.SoundSink != nil { + settings.SoundSink = *values.SoundSink + } + if values.SoundTheme != nil { + settings.SoundTheme = *values.SoundTheme + } + if values.PlaySoundForRole != nil { + settings.PlaySoundForRole = *values.PlaySoundForRole + } + if values.PlaySoundForState != nil { + settings.PlaySoundForState = *values.PlaySoundForState + } + if values.PlaySoundForPositionInSet != nil { + settings.PlaySoundForPositionInSet = *values.PlaySoundForPositionInSet + } + if values.SpatializeObjectSounds != nil { + settings.SpatializeObjectSounds = *values.SpatializeObjectSounds + } + if values.RoleSoundPresentation != nil { + settings.RoleSoundPresentation = *values.RoleSoundPresentation + } +} + +func apply_legacy_output(settings *Settings, values rawSettings) { + if values.EnableSpeech != nil { + settings.EnableSpeech = *values.EnableSpeech + } + if values.EnableBraille != nil { + settings.EnableBraille = *values.EnableBraille + } + if values.EnableContractedBraille != nil { + settings.EnableContractedBraille = *values.EnableContractedBraille + } + if values.BrailleContractionTable != nil { + settings.BrailleContractionTable = *values.BrailleContractionTable + } + if values.BrailleRolenameStyle != nil { + settings.Braille.AbbreviateRoles = *values.BrailleRolenameStyle == 0 + } + if values.BrailleDisableEOL != nil { + settings.Braille.DisableEOL = *values.BrailleDisableEOL + } + if values.BrailleWordWrap != nil { + settings.Braille.WordWrap = *values.BrailleWordWrap + } + if values.BrailleVerbosity != nil { + settings.Braille.Verbosity = *values.BrailleVerbosity + } + if values.BrailleSelectionIndicator != nil { + settings.Braille.SelectionIndicator = *values.BrailleSelectionIndicator + } + if values.BrailleLinkIndicator != nil { + settings.Braille.LinkIndicator = *values.BrailleLinkIndicator + } + if values.BrailleFlashMessages != nil { + settings.Braille.FlashMessages = *values.BrailleFlashMessages + } + if values.BrailleFlashTime != nil { + settings.Braille.FlashTime = *values.BrailleFlashTime + } + if values.BrailleFlashPersistent != nil { + settings.Braille.FlashPersistent = *values.BrailleFlashPersistent + } + if values.BrailleFlashDetailed != nil { + settings.Braille.FlashDetailed = *values.BrailleFlashDetailed + } +} + +func apply_legacy_speech_service(settings *Settings, values rawSettings) { + if values.HardwareSpeechDevice != nil { + settings.HardwareSpeechDevice = *values.HardwareSpeechDevice + } + if values.HardwareSpeechBaudRate != nil { + settings.HardwareSpeechBaudRate = *values.HardwareSpeechBaudRate + } + if values.SpeechServerFactory != nil { + settings.SpeechServerFactory = *values.SpeechServerFactory + } + if settings.SpeechServerFactory == "hardwarefactory" && len(values.SpeechServerInfo) > 1 { + settings.SpeechOutputModule = values.SpeechServerInfo[1] + } + if values.SpeakDescription != nil { + settings.SpeakDescription = *values.SpeakDescription + } + if values.SpeechVerbosityLevel != nil { + settings.SpeechVerbosityLevel = *values.SpeechVerbosityLevel + } + if values.SayAllStyle != nil { + settings.SayAllStyle = map[int]string{0: "line", 1: "sentence"}[*values.SayAllStyle] + } +} + +func apply_legacy_reading(settings *Settings, values rawSettings) { + if values.Reading.OnlySpeakDisplayedText != nil { + settings.Reading.OnlySpeakDisplayedText = *values.Reading.OnlySpeakDisplayedText + } + if values.Reading.SpeakBlankLines != nil { + settings.Reading.SpeakBlankLines = *values.Reading.SpeakBlankLines + } + if values.Reading.EnableMnemonicSpeaking != nil { + settings.Reading.EnableMnemonicSpeaking = *values.Reading.EnableMnemonicSpeaking + } + if values.Reading.EnablePositionSpeaking != nil { + settings.Reading.EnablePositionSpeaking = *values.Reading.EnablePositionSpeaking + } + if values.Reading.EnableTutorialMessages != nil { + settings.Reading.EnableTutorialMessages = *values.Reading.EnableTutorialMessages + } + if values.Reading.MessagesAreDetailed != nil { + settings.Reading.MessagesAreDetailed = *values.Reading.MessagesAreDetailed + } + if values.Reading.UseColorNames != nil { + settings.Reading.UseColorNames = *values.Reading.UseColorNames + } + if values.Reading.PresentTooltips != nil { + settings.Reading.PresentTooltips = *values.Reading.PresentTooltips + } + if values.Reading.EnableMouseReview != nil { + settings.Reading.EnableMouseReview = *values.Reading.EnableMouseReview + } + if values.Reading.SpeakMisspelledIndicator != nil { + settings.Reading.SpeakMisspelledIndicator = *values.Reading.SpeakMisspelledIndicator + } + if values.Reading.EnablePauseBreaks != nil { + settings.Reading.EnablePauseBreaks = *values.Reading.EnablePauseBreaks + } + if values.Reading.SpeakNumbersAsDigits != nil { + settings.Reading.SpeakNumbersAsDigits = *values.Reading.SpeakNumbersAsDigits + } + if values.Reading.SpeakMultiCaseStringsAsWords != nil { + settings.Reading.SpeakMultiCaseStringsAsWords = *values.Reading.SpeakMultiCaseStringsAsWords + } + if values.Reading.EnableIndentation != nil { + settings.Reading.EnableIndentation = *values.Reading.EnableIndentation + } + if values.Reading.SpeakContextBlockquote != nil { + settings.Reading.SpeakContextBlockquote = *values.Reading.SpeakContextBlockquote + } + if values.Reading.SpeakContextList != nil { + settings.Reading.SpeakContextList = *values.Reading.SpeakContextList + } + if values.Reading.SpeakContextTable != nil { + settings.Reading.SpeakContextTable = *values.Reading.SpeakContextTable + } + if values.Reading.SpeakContextPanel != nil { + settings.Reading.SpeakContextPanel = *values.Reading.SpeakContextPanel + } + if values.Reading.SpeakContextLandmark != nil { + settings.Reading.SpeakContextLandmark = *values.Reading.SpeakContextLandmark + } + if values.Reading.SpeakContextNonLandmarkForm != nil { + settings.Reading.SpeakContextNonLandmarkForm = *values.Reading.SpeakContextNonLandmarkForm + } + if values.Reading.SayAllContextBlockquote != nil { + settings.Reading.SayAllContextBlockquote = *values.Reading.SayAllContextBlockquote + } + if values.Reading.SayAllContextList != nil { + settings.Reading.SayAllContextList = *values.Reading.SayAllContextList + } + if values.Reading.SayAllContextTable != nil { + settings.Reading.SayAllContextTable = *values.Reading.SayAllContextTable + } + if values.Reading.SayAllContextPanel != nil { + settings.Reading.SayAllContextPanel = *values.Reading.SayAllContextPanel + } + if values.Reading.SayAllContextLandmark != nil { + settings.Reading.SayAllContextLandmark = *values.Reading.SayAllContextLandmark + } + if values.Reading.SayAllContextNonLandmarkForm != nil { + settings.Reading.SayAllContextNonLandmarkForm = *values.Reading.SayAllContextNonLandmarkForm + } + if values.Reading.ReadFullRowInGUITable != nil { + settings.Reading.ReadFullRowInGUITable = *values.Reading.ReadFullRowInGUITable + } + if values.Reading.ReadFullRowInDocumentTable != nil { + settings.Reading.ReadFullRowInDocumentTable = *values.Reading.ReadFullRowInDocumentTable + } + if values.Reading.ReadFullRowInSpreadSheet != nil { + settings.Reading.ReadFullRowInSpreadSheet = *values.Reading.ReadFullRowInSpreadSheet + } +} + +func apply_legacy_speech_formats(settings *Settings, values rawSettings) { + if values.VerbalizePunctuationStyle != nil { + settings.VerbalizePunctuationStyle = *values.VerbalizePunctuationStyle + } + if values.CapitalizationStyle != nil { + settings.CapitalizationStyle = *values.CapitalizationStyle + } + if values.TimeFormat != nil { + settings.TimeFormat = *values.TimeFormat + } + if values.DateFormat != nil { + settings.DateFormat = datetime.NormalizeDate(*values.DateFormat) + } +} + +func apply_legacy_keyboard(settings *Settings, values rawSettings) { + if values.KeyboardLayout != nil { + settings.KeyboardLayout = *values.KeyboardLayout + } + if values.CthulhuModifierKeys != nil { + settings.CthulhuModifierKeys = append([]string(nil), (*values.CthulhuModifierKeys)...) + } +} + +func apply_legacy_echo(settings *Settings, values rawSettings) { + if values.UseCustomEchoVoice != nil { + settings.UseCustomEchoVoice = *values.UseCustomEchoVoice + } + if values.UseCustomEchoSpeechServer != nil { + settings.UseCustomEchoSpeechServer = *values.UseCustomEchoSpeechServer + } + if values.UseCustomEchoForKey != nil { + settings.UseCustomEchoForKey = *values.UseCustomEchoForKey + } + if values.UseCustomEchoForCharacter != nil { + settings.UseCustomEchoForCharacter = *values.UseCustomEchoForCharacter + } + if values.UseCustomEchoForWord != nil { + settings.UseCustomEchoForWord = *values.UseCustomEchoForWord + } + if values.UseCustomEchoForSentence != nil { + settings.UseCustomEchoForSentence = *values.UseCustomEchoForSentence + } + if values.EchoVoice != nil { + mergeVoice(&settings.EchoVoice, *values.EchoVoice) + } + if len(values.EchoSpeechServerInfo) > 1 { + settings.EchoSpeechServer = values.EchoSpeechServerInfo[1] + } + if values.EnableKeyEcho != nil { + settings.EnableKeyEcho = *values.EnableKeyEcho + } + if values.EnableEchoByCharacter != nil { + settings.EnableEchoByCharacter = *values.EnableEchoByCharacter + } + if values.EnableEchoByWord != nil { + settings.EnableEchoByWord = *values.EnableEchoByWord + } + if values.EnableEchoBySentence != nil { + settings.EnableEchoBySentence = *values.EnableEchoBySentence + } + if values.EnableAlphabeticKeys != nil { + settings.EnableAlphabeticKeys = *values.EnableAlphabeticKeys + } + if values.EnableNumericKeys != nil { + settings.EnableNumericKeys = *values.EnableNumericKeys + } + if values.EnablePunctuationKeys != nil { + settings.EnablePunctuationKeys = *values.EnablePunctuationKeys + } + if values.EnableSpace != nil { + settings.EnableSpace = *values.EnableSpace + } + if values.EnableModifierKeys != nil { + settings.EnableModifierKeys = *values.EnableModifierKeys + } + if values.EnableFunctionKeys != nil { + settings.EnableFunctionKeys = *values.EnableFunctionKeys + } + if values.EnableActionKeys != nil { + settings.EnableActionKeys = *values.EnableActionKeys + } + if values.EnableNavigationKeys != nil { + settings.EnableNavigationKeys = *values.EnableNavigationKeys + } + if values.EnableDiacriticalKeys != nil { + settings.EnableDiacriticalKeys = *values.EnableDiacriticalKeys + } +} + +func apply_legacy_default_voice(settings *Settings, values rawSettings) { + if voice, exists := values.Voices["default"]; exists { + mergeVoice(&settings.DefaultVoice, voice) + } +} + +func mergeVoice(target *Voice, source Voice) { + if source.Rate != nil { + target.Rate = source.Rate + } + if source.AveragePitch != nil { + target.AveragePitch = source.AveragePitch + } + if source.Gain != nil { + target.Gain = source.Gain + } + if source.Family != nil { + family := *source.Family + target.Family = &family + } +} diff --git a/internal/config/ocr_test.go b/internal/config/ocr_test.go new file mode 100644 index 0000000..2fdcc17 --- /dev/null +++ b/internal/config/ocr_test.go @@ -0,0 +1,48 @@ +package config + +import "testing" + +func TestOCRSettingsPersistAndRebase(t *testing.T) { + settings := DefaultSettings() + settings.OCR = OCRSettings{Language: "fra+eng", Layout: 11, Scale: 3, Grayscale: true, + Invert: true, BlackWhite: true, Threshold: 231, AutoRefresh: true, RefreshInterval: 700, + ConfirmClick: false, CopyFirstResult: true, AutoReadAll: true} + encoded, err := encodeWritable(settings) + if err != nil { + t.Fatal(err) + } + decoded, err := decodeWritable(encoded) + if err != nil { + t.Fatal(err) + } + if decoded.OCR != settings.OCR { + t.Fatalf("OCR settings changed: %+v", decoded.OCR) + } + global := DefaultSettings() + application := global + application.OCR.Language = "deu" + global.OCR.AutoReadAll = true + application.OCR.AutoReadAll = false + newGlobal := global + newGlobal.OCR.Scale = 2 + rebased := RebaseApplicationSettings(global, newGlobal, application) + if rebased.OCR.Language != "deu" || rebased.OCR.Scale != 2 || rebased.OCR.AutoReadAll { + t.Fatalf("OCR override rebase: %+v", rebased.OCR) + } +} + +func TestOCRAutoReadAllDefaultsAndExplicitFalse(t *testing.T) { + settings := DefaultSettings() + if settings.OCR.AutoReadAll { + t.Fatal("automatic OCR reading must default off") + } + settings.OCR.AutoReadAll = true + apply_profile_ocr(&settings.OCR, writableOCR{}) + if !settings.OCR.AutoReadAll { + t.Fatal("omitted setting changed inherited value") + } + apply_profile_ocr(&settings.OCR, writableOCR{AutoReadAll: boolPointer(false)}) + if settings.OCR.AutoReadAll { + t.Fatal("explicit false did not override inherited value") + } +} diff --git a/internal/config/patch_contract_test.go b/internal/config/patch_contract_test.go new file mode 100644 index 0000000..23b75a6 --- /dev/null +++ b/internal/config/patch_contract_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import "testing" + +func TestLoadExplicitZeroAndEmptyOverridesAcrossSchemas(t *testing.T) { + for name, contents := range map[string]string{ + "legacy profile overrides general": ` +[general] +enableSpeech = true +enableBraille = true +soundVolume = 0.8 +cthulhuModifierKeys = ["Insert"] +echoSpeechServerInfo = ["server", "test-module"] +[general.voices.default] +rate = 75 +[profiles.default] +enableSpeech = false +enableBraille = false +soundVolume = 0.0 +playSoundForRole = false +cthulhuModifierKeys = [] +echoSpeechServerInfo = ["server", ""] +[profiles.default.reading] +speakBlankLines = false +[profiles.default.voices.default] +rate = 0 +`, + "versioned patch overrides defaults": ` +schema-version = 2 +active-profile = "default" +[profiles.default.speech] +enabled = false +[profiles.default.speech.default-voice] +rate = 0 +[profiles.default.braille] +enabled = false +[profiles.default.sound] +volume = 0.0 +play-for-role = false +[profiles.default.keyboard] +screen-reader-keys = [] +[profiles.default.key-echo] +speech-server = "" +[profiles.default.presentation.reading] +speak-blank-lines = false +`, + } { + t.Run(name, func(t *testing.T) { + settings, err := Load(writeConfig(t, contents)) + if err != nil { + t.Fatal(err) + } + if settings.EnableSpeech || settings.EnableBraille || settings.SoundVolume != 0 || + settings.PlaySoundForRole || settings.Reading.SpeakBlankLines || + len(settings.CthulhuModifierKeys) != 0 || settings.EchoSpeechServer != "" || + settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 0 { + t.Fatalf("explicit false/zero/empty values were lost: %+v", settings) + } + if !settings.EnableSound || !settings.PlaySoundForState || !settings.EnableKeyEcho || + !settings.Reading.ReadFullRowInGUITable || settings.DefaultVoice.Gain != nil { + t.Fatalf("omitted fields changed their defaults: %+v", settings) + } + }) + } +} diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go new file mode 100644 index 0000000..da7f4d4 --- /dev/null +++ b/internal/config/profile_mapping.go @@ -0,0 +1,576 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "reflect" + + "git.stormux.org/storm/cthulhu/internal/datetime" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// Application profiles store only values that differ from the global profile. +// The writable schema is made of nested value structs and pointer fields, so +// clearing equal leaves preserves its existing patch semantics. +func applicationProfileDifference(global, application Settings) writableProfile { + base := profileFromSettings(global) + result := profileFromSettings(application) + clearEqualProfileFields(reflect.ValueOf(&result).Elem(), reflect.ValueOf(base)) + return result +} + +func clearEqualProfileFields(result, base reflect.Value) { + for index := 0; index < result.NumField(); index++ { + field, baseline := result.Field(index), base.Field(index) + if field.Kind() == reflect.Struct { + clearEqualProfileFields(field, baseline) + } else if reflect.DeepEqual(field.Interface(), baseline.Interface()) { + field.SetZero() + } + } +} + +// RebaseApplicationSettings keeps session-only application choices when the +// global profile changes, while newly inherited values follow the new global. +func RebaseApplicationSettings(oldGlobal, newGlobal, application Settings) Settings { + patch := applicationProfileDifference(oldGlobal, application) + rebased := newGlobal + applyProfile(&rebased, patch) + rebased.ActiveProfile = application.ActiveProfile + return rebased +} + +func profileFromSettings(settings Settings) writableProfile { + return writableProfile{ + OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)}, + ProgressBar: writableProgressBar{ + OutputMode: stringPointer(settings.ProgressBar.OutputMode), + ReportBackground: boolPointer(settings.ProgressBar.ReportBackground), + BeepPercentageInterval: intPointer(settings.ProgressBar.BeepPercentageInterval), + SpeechPercentageInterval: intPointer(settings.ProgressBar.SpeechPercentageInterval), + }, + Presentation: writablePresentation{ + Pronunciations: pronunciationSlicePointer(settings.Pronunciations), + TextAttributes: writableTextAttributes{Choices: textAttributeSlicePointer(settings.TextAttributes.Choices), Indicator: intPointer(settings.TextAttributes.Indicator)}, + TimeFormat: stringPointer(settings.TimeFormat), DateFormat: stringPointer(settings.DateFormat), + SpeakDescription: boolPointer(settings.SpeakDescription), + SpeechVerbosityLevel: intPointer(settings.SpeechVerbosityLevel), + SayAllStyle: stringPointer(settings.SayAllStyle), + Reading: writableReading{ + AutomaticallyReadClipboardChanges: boolPointer(settings.Reading.AutomaticallyReadClipboardChanges), + AutomaticallyReadTitleChanges: boolPointer(settings.Reading.AutomaticallyReadTitleChanges), + EnablePauseBreaks: boolPointer(settings.Reading.EnablePauseBreaks), + SpeakNumbersAsDigits: boolPointer(settings.Reading.SpeakNumbersAsDigits), + SpeakMultiCaseStringsAsWords: boolPointer(settings.Reading.SpeakMultiCaseStringsAsWords), + EnableIndentation: boolPointer(settings.Reading.EnableIndentation), + IndentationPresentationMode: stringPointer(settings.Reading.IndentationPresentationMode), + OnlySpeakDisplayedText: boolPointer(settings.Reading.OnlySpeakDisplayedText), + SpeakBlankLines: boolPointer(settings.Reading.SpeakBlankLines), + EnableMnemonicSpeaking: boolPointer(settings.Reading.EnableMnemonicSpeaking), + EnablePositionSpeaking: boolPointer(settings.Reading.EnablePositionSpeaking), + EnableTutorialMessages: boolPointer(settings.Reading.EnableTutorialMessages), + MessagesAreDetailed: boolPointer(settings.Reading.MessagesAreDetailed), + UseColorNames: boolPointer(settings.Reading.UseColorNames), + PresentTooltips: boolPointer(settings.Reading.PresentTooltips), + EnableMouseReview: boolPointer(settings.Reading.EnableMouseReview), + SpeakMisspelledIndicator: boolPointer(settings.Reading.SpeakMisspelledIndicator), + SpeakContextBlockquote: boolPointer(settings.Reading.SpeakContextBlockquote), + SpeakContextList: boolPointer(settings.Reading.SpeakContextList), + SpeakContextTable: boolPointer(settings.Reading.SpeakContextTable), + SpeakContextPanel: boolPointer(settings.Reading.SpeakContextPanel), + SpeakContextLandmark: boolPointer(settings.Reading.SpeakContextLandmark), + SpeakContextNonLandmarkForm: boolPointer(settings.Reading.SpeakContextNonLandmarkForm), + SayAllContextBlockquote: boolPointer(settings.Reading.SayAllContextBlockquote), + SayAllContextList: boolPointer(settings.Reading.SayAllContextList), + SayAllContextTable: boolPointer(settings.Reading.SayAllContextTable), + SayAllContextPanel: boolPointer(settings.Reading.SayAllContextPanel), + SayAllContextLandmark: boolPointer(settings.Reading.SayAllContextLandmark), + SayAllContextNonLandmarkForm: boolPointer(settings.Reading.SayAllContextNonLandmarkForm), + ReadFullRowInGUITable: boolPointer(settings.Reading.ReadFullRowInGUITable), + ReadFullRowInDocumentTable: boolPointer(settings.Reading.ReadFullRowInDocumentTable), + ReadFullRowInSpreadSheet: boolPointer(settings.Reading.ReadFullRowInSpreadSheet), + }, + }, + Speech: writableSpeech{ + HardwareDevice: stringPointer(settings.HardwareSpeechDevice), + HardwareBaudRate: intPointer(settings.HardwareSpeechBaudRate), + Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory), + HistoryLimit: intPointer(settings.SpeechHistoryLimit), + Punctuation: intPointer(settings.VerbalizePunctuationStyle), Capitalization: stringPointer(settings.CapitalizationStyle), + DefaultVoice: voicePointer(settings.DefaultVoice), + UppercaseVoice: voicePointer(settings.UppercaseVoice), + HyperlinkVoice: voicePointer(settings.HyperlinkVoice), + SystemVoice: voicePointer(settings.SystemVoice), + OutputModule: stringPointer(settings.SpeechOutputModule), + }, + Braille: writableBraille{Enabled: boolPointer(settings.EnableBraille), + Contracted: boolPointer(settings.EnableContractedBraille), + ContractionTable: stringPointer(settings.BrailleContractionTable), + AbbreviateRoles: boolPointer(settings.Braille.AbbreviateRoles), + DisableEOL: boolPointer(settings.Braille.DisableEOL), WordWrap: boolPointer(settings.Braille.WordWrap), + Verbosity: intPointer(settings.Braille.Verbosity), SelectionIndicator: intPointer(settings.Braille.SelectionIndicator), + LinkIndicator: intPointer(settings.Braille.LinkIndicator), FlashMessages: boolPointer(settings.Braille.FlashMessages), + FlashTime: intPointer(settings.Braille.FlashTime), FlashPersistent: boolPointer(settings.Braille.FlashPersistent), + FlashDetailed: boolPointer(settings.Braille.FlashDetailed)}, + Sound: writableSound{ + Enabled: boolPointer(settings.EnableSound), Volume: floatPointer(settings.SoundVolume), + Sink: stringPointer(settings.SoundSink), Theme: stringPointer(settings.SoundTheme), + PlayForRole: boolPointer(settings.PlaySoundForRole), PlayForState: boolPointer(settings.PlaySoundForState), + PlayForPosition: boolPointer(settings.PlaySoundForPositionInSet), + Spatialize: boolPointer(settings.SpatializeObjectSounds), + RolePresentationStrategy: stringPointer(settings.RoleSoundPresentation), + }, + Keyboard: writableKeyboard{ + Bindings: bindingSlicePointer(settings.KeyBindings), + Layout: intPointer(settings.KeyboardLayout), + ScreenReaderKeys: stringSlicePointer(settings.CthulhuModifierKeys), + }, + KeyEcho: writableKeyEcho{ + UseCustomEchoVoice: boolPointer(settings.UseCustomEchoVoice), + UseCustomEchoSpeechServer: boolPointer(settings.UseCustomEchoSpeechServer), + UseCustomEchoForKey: boolPointer(settings.UseCustomEchoForKey), + UseCustomEchoForCharacter: boolPointer(settings.UseCustomEchoForCharacter), + UseCustomEchoForWord: boolPointer(settings.UseCustomEchoForWord), + UseCustomEchoForSentence: boolPointer(settings.UseCustomEchoForSentence), + EchoVoice: voicePointer(settings.EchoVoice), EchoSpeechServer: stringPointer(settings.EchoSpeechServer), + + Enabled: boolPointer(settings.EnableKeyEcho), ByCharacter: boolPointer(settings.EnableEchoByCharacter), + ByWord: boolPointer(settings.EnableEchoByWord), BySentence: boolPointer(settings.EnableEchoBySentence), + Alphabetic: boolPointer(settings.EnableAlphabeticKeys), Numeric: boolPointer(settings.EnableNumericKeys), + Punctuation: boolPointer(settings.EnablePunctuationKeys), Space: boolPointer(settings.EnableSpace), + Modifiers: boolPointer(settings.EnableModifierKeys), FunctionKeys: boolPointer(settings.EnableFunctionKeys), + ActionKeys: boolPointer(settings.EnableActionKeys), NavigationKeys: boolPointer(settings.EnableNavigationKeys), + Diacritical: boolPointer(settings.EnableDiacriticalKeys), + }, + } +} + +func applyProfile(settings *Settings, profile writableProfile) { + apply_profile_ocr(&settings.OCR, profile.OCR) + // Keep the established patch order; absent fields leave prior values intact. + apply_profile_formats(settings, profile) + apply_profile_echo_voice(settings, profile) + apply_profile_reading(settings, profile) + apply_profile_output(settings, profile) + apply_profile_speech(settings, profile) + apply_profile_sound(settings, profile) + if value := profile.ProgressBar.OutputMode; value != nil { + settings.ProgressBar.OutputMode = *value + } + if value := profile.ProgressBar.ReportBackground; value != nil { + settings.ProgressBar.ReportBackground = *value + } + if value := profile.ProgressBar.BeepPercentageInterval; value != nil { + settings.ProgressBar.BeepPercentageInterval = *value + } + if value := profile.ProgressBar.SpeechPercentageInterval; value != nil { + settings.ProgressBar.SpeechPercentageInterval = *value + } + apply_profile_keyboard(settings, profile) + apply_profile_echo_keys(settings, profile) +} + +func apply_profile_ocr(settings *OCRSettings, patch writableOCR) { + if patch.Language != nil { + settings.Language = *patch.Language + } + if patch.Layout != nil { + settings.Layout = *patch.Layout + } + if patch.Scale != nil { + settings.Scale = *patch.Scale + } + if patch.Grayscale != nil { + settings.Grayscale = *patch.Grayscale + } + if patch.Invert != nil { + settings.Invert = *patch.Invert + } + if patch.BlackWhite != nil { + settings.BlackWhite = *patch.BlackWhite + } + if patch.Threshold != nil { + settings.Threshold = *patch.Threshold + } + if patch.AutoRefresh != nil { + settings.AutoRefresh = *patch.AutoRefresh + } + if patch.RefreshInterval != nil { + settings.RefreshInterval = *patch.RefreshInterval + } + if patch.ConfirmClick != nil { + settings.ConfirmClick = *patch.ConfirmClick + } + if patch.CopyFirstResult != nil { + settings.CopyFirstResult = *patch.CopyFirstResult + } + if patch.AutoReadAll != nil { + settings.AutoReadAll = *patch.AutoReadAll + } +} + +func apply_profile_formats(settings *Settings, profile writableProfile) { + if v := profile.Presentation.TimeFormat; v != nil { + settings.TimeFormat = *v + } + if v := profile.Presentation.DateFormat; v != nil { + settings.DateFormat = datetime.NormalizeDate(*v) + } +} + +func apply_profile_echo_voice(settings *Settings, profile writableProfile) { + if v := profile.KeyEcho.UseCustomEchoVoice; v != nil { + settings.UseCustomEchoVoice = *v + } + if v := profile.KeyEcho.UseCustomEchoSpeechServer; v != nil { + settings.UseCustomEchoSpeechServer = *v + } + if v := profile.KeyEcho.UseCustomEchoForKey; v != nil { + settings.UseCustomEchoForKey = *v + } + if v := profile.KeyEcho.UseCustomEchoForCharacter; v != nil { + settings.UseCustomEchoForCharacter = *v + } + if v := profile.KeyEcho.UseCustomEchoForWord; v != nil { + settings.UseCustomEchoForWord = *v + } + if v := profile.KeyEcho.UseCustomEchoForSentence; v != nil { + settings.UseCustomEchoForSentence = *v + } + if v := profile.KeyEcho.EchoVoice; v != nil { + settings.EchoVoice = cloneVoice(*v) + } + if v := profile.KeyEcho.EchoSpeechServer; v != nil { + settings.EchoSpeechServer = *v + } +} + +func apply_profile_reading(settings *Settings, profile writableProfile) { + if value := profile.Presentation.Pronunciations; value != nil { + settings.Pronunciations = append([]PronunciationEntry(nil), (*value)...) + } + if value := profile.Presentation.TextAttributes.Choices; value != nil { + settings.TextAttributes.Choices = append([]TextAttributeChoice(nil), (*value)...) + } + if value := profile.Presentation.TextAttributes.Indicator; value != nil { + settings.TextAttributes.Indicator = *value + } + if value := profile.Presentation.SpeakDescription; value != nil { + settings.SpeakDescription = *value + } + if value := profile.Presentation.SpeechVerbosityLevel; value != nil { + settings.SpeechVerbosityLevel = *value + } + if value := profile.Presentation.SayAllStyle; value != nil { + settings.SayAllStyle = *value + } + if value := profile.Presentation.Reading.AutomaticallyReadClipboardChanges; value != nil { + settings.Reading.AutomaticallyReadClipboardChanges = *value + } + if value := profile.Presentation.Reading.AutomaticallyReadTitleChanges; value != nil { + settings.Reading.AutomaticallyReadTitleChanges = *value + } + if value := profile.Presentation.Reading.OnlySpeakDisplayedText; value != nil { + settings.Reading.OnlySpeakDisplayedText = *value + } + if value := profile.Presentation.Reading.EnablePauseBreaks; value != nil { + settings.Reading.EnablePauseBreaks = *value + } + if value := profile.Presentation.Reading.SpeakNumbersAsDigits; value != nil { + settings.Reading.SpeakNumbersAsDigits = *value + } + if value := profile.Presentation.Reading.SpeakMultiCaseStringsAsWords; value != nil { + settings.Reading.SpeakMultiCaseStringsAsWords = *value + } + if value := profile.Presentation.Reading.EnableIndentation; value != nil { + settings.Reading.EnableIndentation = *value + } + if value := profile.Presentation.Reading.IndentationPresentationMode; value != nil { + settings.Reading.IndentationPresentationMode = *value + } + if value := profile.Presentation.Reading.SpeakBlankLines; value != nil { + settings.Reading.SpeakBlankLines = *value + } + if value := profile.Presentation.Reading.EnableMnemonicSpeaking; value != nil { + settings.Reading.EnableMnemonicSpeaking = *value + } + if value := profile.Presentation.Reading.EnablePositionSpeaking; value != nil { + settings.Reading.EnablePositionSpeaking = *value + } + if value := profile.Presentation.Reading.EnableTutorialMessages; value != nil { + settings.Reading.EnableTutorialMessages = *value + } + if value := profile.Presentation.Reading.MessagesAreDetailed; value != nil { + settings.Reading.MessagesAreDetailed = *value + } + if value := profile.Presentation.Reading.UseColorNames; value != nil { + settings.Reading.UseColorNames = *value + } + if value := profile.Presentation.Reading.PresentTooltips; value != nil { + settings.Reading.PresentTooltips = *value + } + if value := profile.Presentation.Reading.EnableMouseReview; value != nil { + settings.Reading.EnableMouseReview = *value + } + if value := profile.Presentation.Reading.SpeakMisspelledIndicator; value != nil { + settings.Reading.SpeakMisspelledIndicator = *value + } + if value := profile.Presentation.Reading.SpeakContextBlockquote; value != nil { + settings.Reading.SpeakContextBlockquote = *value + } + if value := profile.Presentation.Reading.SpeakContextList; value != nil { + settings.Reading.SpeakContextList = *value + } + if value := profile.Presentation.Reading.SpeakContextTable; value != nil { + settings.Reading.SpeakContextTable = *value + } + if value := profile.Presentation.Reading.SpeakContextPanel; value != nil { + settings.Reading.SpeakContextPanel = *value + } + if value := profile.Presentation.Reading.SpeakContextLandmark; value != nil { + settings.Reading.SpeakContextLandmark = *value + } + if value := profile.Presentation.Reading.SpeakContextNonLandmarkForm; value != nil { + settings.Reading.SpeakContextNonLandmarkForm = *value + } + if value := profile.Presentation.Reading.SayAllContextBlockquote; value != nil { + settings.Reading.SayAllContextBlockquote = *value + } + if value := profile.Presentation.Reading.SayAllContextList; value != nil { + settings.Reading.SayAllContextList = *value + } + if value := profile.Presentation.Reading.SayAllContextTable; value != nil { + settings.Reading.SayAllContextTable = *value + } + if value := profile.Presentation.Reading.SayAllContextPanel; value != nil { + settings.Reading.SayAllContextPanel = *value + } + if value := profile.Presentation.Reading.SayAllContextLandmark; value != nil { + settings.Reading.SayAllContextLandmark = *value + } + if value := profile.Presentation.Reading.SayAllContextNonLandmarkForm; value != nil { + settings.Reading.SayAllContextNonLandmarkForm = *value + } + if value := profile.Presentation.Reading.ReadFullRowInGUITable; value != nil { + settings.Reading.ReadFullRowInGUITable = *value + } + if value := profile.Presentation.Reading.ReadFullRowInDocumentTable; value != nil { + settings.Reading.ReadFullRowInDocumentTable = *value + } + if value := profile.Presentation.Reading.ReadFullRowInSpreadSheet; value != nil { + settings.Reading.ReadFullRowInSpreadSheet = *value + } +} + +func apply_profile_output(settings *Settings, profile writableProfile) { + if value := profile.Speech.Enabled; value != nil { + settings.EnableSpeech = *value + } + if value := profile.Speech.HistoryLimit; value != nil { + settings.SpeechHistoryLimit = *value + } + if value := profile.Braille.Enabled; value != nil { + settings.EnableBraille = *value + } + if value := profile.Braille.Contracted; value != nil { + settings.EnableContractedBraille = *value + } + if value := profile.Braille.ContractionTable; value != nil { + settings.BrailleContractionTable = *value + } + if value := profile.Braille.AbbreviateRoles; value != nil { + settings.Braille.AbbreviateRoles = *value + } + if value := profile.Braille.DisableEOL; value != nil { + settings.Braille.DisableEOL = *value + } + if value := profile.Braille.WordWrap; value != nil { + settings.Braille.WordWrap = *value + } + if value := profile.Braille.Verbosity; value != nil { + settings.Braille.Verbosity = *value + } + if value := profile.Braille.SelectionIndicator; value != nil { + settings.Braille.SelectionIndicator = *value + } + if value := profile.Braille.LinkIndicator; value != nil { + settings.Braille.LinkIndicator = *value + } + if value := profile.Braille.FlashMessages; value != nil { + settings.Braille.FlashMessages = *value + } + if value := profile.Braille.FlashTime; value != nil { + settings.Braille.FlashTime = *value + } + if value := profile.Braille.FlashPersistent; value != nil { + settings.Braille.FlashPersistent = *value + } + if value := profile.Braille.FlashDetailed; value != nil { + settings.Braille.FlashDetailed = *value + } +} + +func apply_profile_speech(settings *Settings, profile writableProfile) { + if value := profile.Speech.HardwareDevice; value != nil { + settings.HardwareSpeechDevice = *value + } + if value := profile.Speech.HardwareBaudRate; value != nil { + settings.HardwareSpeechBaudRate = *value + } + if value := profile.Speech.ServerFactory; value != nil { + settings.SpeechServerFactory = *value + } + if value := profile.Speech.Punctuation; value != nil { + settings.VerbalizePunctuationStyle = *value + } + if value := profile.Speech.Capitalization; value != nil { + settings.CapitalizationStyle = *value + } + if value := profile.Speech.OutputModule; value != nil { + settings.SpeechOutputModule = *value + } + if value := profile.Speech.DefaultVoice; value != nil { + settings.DefaultVoice = cloneVoice(*value) + } + if value := profile.Speech.UppercaseVoice; value != nil { + settings.UppercaseVoice = cloneVoice(*value) + } + if value := profile.Speech.HyperlinkVoice; value != nil { + settings.HyperlinkVoice = cloneVoice(*value) + } + if value := profile.Speech.SystemVoice; value != nil { + settings.SystemVoice = cloneVoice(*value) + } +} + +func apply_profile_sound(settings *Settings, profile writableProfile) { + if value := profile.Sound.Enabled; value != nil { + settings.EnableSound = *value + } + if value := profile.Sound.Volume; value != nil { + settings.SoundVolume = *value + } + if value := profile.Sound.Sink; value != nil { + settings.SoundSink = *value + } + if value := profile.Sound.Theme; value != nil { + settings.SoundTheme = *value + } + if value := profile.Sound.PlayForRole; value != nil { + settings.PlaySoundForRole = *value + } + if value := profile.Sound.PlayForState; value != nil { + settings.PlaySoundForState = *value + } + if value := profile.Sound.PlayForPosition; value != nil { + settings.PlaySoundForPositionInSet = *value + } + if value := profile.Sound.Spatialize; value != nil { + settings.SpatializeObjectSounds = *value + } + if value := profile.Sound.RolePresentationStrategy; value != nil { + settings.RoleSoundPresentation = *value + } +} + +func apply_profile_keyboard(settings *Settings, profile writableProfile) { + if value := profile.Keyboard.Bindings; value != nil { + settings.KeyBindings = input.CloneBindingOverrides(*value) + } + if value := profile.Keyboard.Layout; value != nil { + settings.KeyboardLayout = *value + } + if value := profile.Keyboard.ScreenReaderKeys; value != nil { + settings.CthulhuModifierKeys = append([]string(nil), (*value)...) + } +} + +func apply_profile_echo_keys(settings *Settings, profile writableProfile) { + if value := profile.KeyEcho.Enabled; value != nil { + settings.EnableKeyEcho = *value + } + if value := profile.KeyEcho.ByCharacter; value != nil { + settings.EnableEchoByCharacter = *value + } + if value := profile.KeyEcho.ByWord; value != nil { + settings.EnableEchoByWord = *value + } + if value := profile.KeyEcho.BySentence; value != nil { + settings.EnableEchoBySentence = *value + } + if value := profile.KeyEcho.Alphabetic; value != nil { + settings.EnableAlphabeticKeys = *value + } + if value := profile.KeyEcho.Numeric; value != nil { + settings.EnableNumericKeys = *value + } + if value := profile.KeyEcho.Punctuation; value != nil { + settings.EnablePunctuationKeys = *value + } + if value := profile.KeyEcho.Space; value != nil { + settings.EnableSpace = *value + } + if value := profile.KeyEcho.Modifiers; value != nil { + settings.EnableModifierKeys = *value + } + if value := profile.KeyEcho.FunctionKeys; value != nil { + settings.EnableFunctionKeys = *value + } + if value := profile.KeyEcho.ActionKeys; value != nil { + settings.EnableActionKeys = *value + } + if value := profile.KeyEcho.NavigationKeys; value != nil { + settings.EnableNavigationKeys = *value + } + if value := profile.KeyEcho.Diacritical; value != nil { + settings.EnableDiacriticalKeys = *value + } +} + +func boolPointer(value bool) *bool { return &value } + +func intPointer(value int) *int { return &value } + +func floatPointer(value float64) *float64 { return &value } + +func stringPointer(value string) *string { return &value } + +func stringSlicePointer(value []string) *[]string { + copy := append([]string(nil), value...) + return © +} + +func pronunciationSlicePointer(value []PronunciationEntry) *[]PronunciationEntry { + copy := append([]PronunciationEntry{}, value...) + return © +} + +func textAttributeSlicePointer(value []TextAttributeChoice) *[]TextAttributeChoice { + copy := append([]TextAttributeChoice{}, value...) + return © +} + +func voicePointer(value Voice) *Voice { copy := cloneVoice(value); return © } + +func bindingSlicePointer(value []input.BindingOverride) *[]input.BindingOverride { + copy := input.CloneBindingOverrides(value) + if copy == nil { + copy = []input.BindingOverride{} + } + return © +} diff --git a/internal/config/progress_test.go b/internal/config/progress_test.go new file mode 100644 index 0000000..125188d --- /dev/null +++ b/internal/config/progress_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProgressSettingsPersistenceAndInheritance(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + global := DefaultSettings() + global.ProgressBar.OutputMode = "both" + global.ProgressBar.ReportBackground = true + if err := store.Save(global); err != nil { + t.Fatal(err) + } + application := global + application.ProgressBar.OutputMode = "off" + if err := store.SaveForApplication("Example Application", application); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil || loaded.ProgressBar != global.ProgressBar { + t.Fatalf("global progress: %+v, %v", loaded.ProgressBar, err) + } + app, found, err := store.LoadForApplication("Example Application") + if err != nil || !found || app.ProgressBar != application.ProgressBar { + t.Fatalf("application progress: %+v, %v", app.ProgressBar, err) + } + global.ProgressBar.ReportBackground = false + global.ProgressBar.SpeechPercentageInterval = 5 + if err := store.Save(global); err != nil { + t.Fatal(err) + } + app, _, err = store.LoadForApplication("Example Application") + if err != nil || app.ProgressBar.OutputMode != "off" || app.ProgressBar.ReportBackground || app.ProgressBar.SpeechPercentageInterval != 5 { + t.Fatalf("inherited progress: %+v, %v", app.ProgressBar, err) + } +} + +func TestObsoleteProgressFieldsDoNotBreakExistingFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + contents := "schema-version = 2\nactive-profile = 'default'\n[profiles.default.sound]\nbeep-progress-updates = false\nprogress-beep-interval = 7\nplay-for-value = true\n" + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings, err := store.Load() + if err != nil || settings.ProgressBar != DefaultProgressBarSettings() { + t.Fatalf("existing settings: %+v, %v", settings.ProgressBar, err) + } + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"beep-progress-updates", "progress-beep-interval", "play-for-value"} { + if strings.Contains(string(data), key) { + t.Fatalf("obsolete field %s was saved", key) + } + } +} + +func TestInvalidProgressSettingsRejected(t *testing.T) { + for _, change := range []func(*ProgressBarSettings){ + func(s *ProgressBarSettings) { s.OutputMode = "invalid" }, + func(s *ProgressBarSettings) { s.BeepPercentageInterval = 0 }, + func(s *ProgressBarSettings) { s.SpeechPercentageInterval = 101 }, + } { + settings := DefaultSettings() + change(&settings.ProgressBar) + if Validate(settings) == nil { + t.Fatalf("invalid progress settings accepted: %+v", settings.ProgressBar) + } + } +} diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..1211d4e --- /dev/null +++ b/internal/config/schema.go @@ -0,0 +1,196 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "git.stormux.org/storm/cthulhu/internal/input" +) + +const CurrentSchemaVersion = 2 + +type writableDocument struct { + SchemaVersion int `toml:"schema-version"` + ActiveProfile string `toml:"active-profile"` + Profiles map[string]writableProfile `toml:"profiles"` + Applications map[string]string `toml:"applications,omitempty"` +} + +// Profiles are patches over built-in defaults. The writer emits a complete +// active profile so saved behavior remains stable if defaults later change. +type writableProfile struct { + OCR writableOCR `toml:"ocr"` + ProgressBar writableProgressBar `toml:"progress-bar"` + Presentation writablePresentation `toml:"presentation"` + Speech writableSpeech `toml:"speech"` + Braille writableBraille `toml:"braille"` + Sound writableSound `toml:"sound"` + Keyboard writableKeyboard `toml:"keyboard"` + KeyEcho writableKeyEcho `toml:"key-echo"` +} + +type writableOCR struct { + Language *string `toml:"language,omitempty"` + Layout *int `toml:"layout,omitempty"` + Scale *int `toml:"scale,omitempty"` + Grayscale *bool `toml:"grayscale,omitempty"` + Invert *bool `toml:"invert,omitempty"` + BlackWhite *bool `toml:"black-white,omitempty"` + Threshold *int `toml:"threshold,omitempty"` + AutoRefresh *bool `toml:"auto-refresh,omitempty"` + RefreshInterval *int `toml:"refresh-interval-ms,omitempty"` + ConfirmClick *bool `toml:"confirm-click,omitempty"` + CopyFirstResult *bool `toml:"copy-first-result,omitempty"` + AutoReadAll *bool `toml:"auto-read-all,omitempty"` +} + +type writableProgressBar struct { + OutputMode *string `toml:"output-mode,omitempty"` + ReportBackground *bool `toml:"report-background,omitempty"` + BeepPercentageInterval *int `toml:"beep-percentage-interval,omitempty"` + SpeechPercentageInterval *int `toml:"speech-percentage-interval,omitempty"` +} + +type writablePresentation struct { + TimeFormat *string `toml:"time-format,omitempty"` + DateFormat *string `toml:"date-format,omitempty"` + SpeakDescription *bool `toml:"speak-description,omitempty"` + SpeechVerbosityLevel *int `toml:"speech-verbosity-level,omitempty"` + SayAllStyle *string `toml:"say-all-style,omitempty"` + Reading writableReading `toml:"reading,omitempty"` + Pronunciations *[]PronunciationEntry `toml:"pronunciations,omitempty"` + TextAttributes writableTextAttributes `toml:"text-attributes,omitempty"` +} + +type writableTextAttributes struct { + Choices *[]TextAttributeChoice `toml:"choices,omitempty"` + Indicator *int `toml:"indicator,omitempty"` +} + +type writableReading struct { + AutomaticallyReadClipboardChanges *bool `toml:"automatically-read-clipboard-changes,omitempty"` + AutomaticallyReadTitleChanges *bool `toml:"automatically-read-title-changes,omitempty"` + EnablePauseBreaks *bool `toml:"enable-pause-breaks,omitempty"` + SpeakNumbersAsDigits *bool `toml:"speak-numbers-as-digits,omitempty"` + SpeakMultiCaseStringsAsWords *bool `toml:"speak-multicase-strings-as-words,omitempty"` + EnableIndentation *bool `toml:"enable-indentation,omitempty"` + IndentationPresentationMode *string `toml:"indentation-presentation-mode,omitempty"` + OnlySpeakDisplayedText *bool `toml:"only-speak-displayed-text,omitempty"` + SpeakBlankLines *bool `toml:"speak-blank-lines,omitempty"` + EnableMnemonicSpeaking *bool `toml:"enable-mnemonic-speaking,omitempty"` + EnablePositionSpeaking *bool `toml:"enable-position-speaking,omitempty"` + EnableTutorialMessages *bool `toml:"enable-tutorial-messages,omitempty"` + MessagesAreDetailed *bool `toml:"messages-are-detailed,omitempty"` + UseColorNames *bool `toml:"use-color-names,omitempty"` + PresentTooltips *bool `toml:"present-tooltips,omitempty"` + EnableMouseReview *bool `toml:"enable-mouse-review,omitempty"` + SpeakMisspelledIndicator *bool `toml:"speak-misspelled-indicator,omitempty"` + SpeakContextBlockquote *bool `toml:"speak-context-blockquote,omitempty"` + SpeakContextList *bool `toml:"speak-context-list,omitempty"` + SpeakContextTable *bool `toml:"speak-context-table,omitempty"` + SpeakContextPanel *bool `toml:"speak-context-panel,omitempty"` + SpeakContextLandmark *bool `toml:"speak-context-landmark,omitempty"` + SpeakContextNonLandmarkForm *bool `toml:"speak-context-non-landmark-form,omitempty"` + SayAllContextBlockquote *bool `toml:"say-all-context-blockquote,omitempty"` + SayAllContextList *bool `toml:"say-all-context-list,omitempty"` + SayAllContextTable *bool `toml:"say-all-context-table,omitempty"` + SayAllContextPanel *bool `toml:"say-all-context-panel,omitempty"` + SayAllContextLandmark *bool `toml:"say-all-context-landmark,omitempty"` + SayAllContextNonLandmarkForm *bool `toml:"say-all-context-non-landmark-form,omitempty"` + ReadFullRowInGUITable *bool `toml:"read-full-row-in-gui-table,omitempty"` + ReadFullRowInDocumentTable *bool `toml:"read-full-row-in-document-table,omitempty"` + ReadFullRowInSpreadSheet *bool `toml:"read-full-row-in-spreadsheet,omitempty"` +} + +type writableSpeech struct { + HardwareDevice *string `toml:"hardware-device,omitempty"` + HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"` + OutputModule *string `toml:"output-module,omitempty"` + Enabled *bool `toml:"enabled,omitempty"` + HistoryLimit *int `toml:"history-limit,omitempty"` + ServerFactory *string `toml:"server-factory,omitempty"` + Punctuation *int `toml:"punctuation-style,omitempty"` + Capitalization *string `toml:"capitalization-style,omitempty"` + DefaultVoice *Voice `toml:"default-voice,omitempty"` + UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"` + HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"` + SystemVoice *Voice `toml:"system-voice,omitempty"` +} + +type writableBraille struct { + Enabled *bool `toml:"enabled,omitempty"` + Contracted *bool `toml:"contracted,omitempty"` + ContractionTable *string `toml:"contraction-table,omitempty"` + AbbreviateRoles *bool `toml:"abbreviate-roles,omitempty"` + DisableEOL *bool `toml:"disable-eol,omitempty"` + WordWrap *bool `toml:"word-wrap,omitempty"` + Verbosity *int `toml:"verbosity,omitempty"` + SelectionIndicator *int `toml:"selection-indicator,omitempty"` + LinkIndicator *int `toml:"link-indicator,omitempty"` + FlashMessages *bool `toml:"flash-messages,omitempty"` + FlashTime *int `toml:"flash-time,omitempty"` + FlashPersistent *bool `toml:"flash-persistent,omitempty"` + FlashDetailed *bool `toml:"flash-detailed,omitempty"` +} + +type writableSound struct { + // Retain recognition of existing version 2 fields. They are no longer used + // or emitted when saving settings; progress output has its own policy. + PlayForValue *bool `toml:"play-for-value,omitempty"` + BeepProgressUpdates *bool `toml:"beep-progress-updates,omitempty"` + ProgressBeepInterval *int `toml:"progress-beep-interval,omitempty"` + Enabled *bool `toml:"enabled,omitempty"` + Volume *float64 `toml:"volume,omitempty"` + Sink *string `toml:"sink,omitempty"` + Theme *string `toml:"theme,omitempty"` + PlayForRole *bool `toml:"play-for-role,omitempty"` + PlayForState *bool `toml:"play-for-state,omitempty"` + PlayForPosition *bool `toml:"play-for-position,omitempty"` + Spatialize *bool `toml:"spatialize,omitempty"` + RolePresentationStrategy *string `toml:"role-presentation-strategy,omitempty"` +} + +type writableKeyboard struct { + Bindings *[]input.BindingOverride `toml:"bindings,omitempty"` + Layout *int `toml:"layout,omitempty"` + ScreenReaderKeys *[]string `toml:"screen-reader-keys,omitempty"` +} + +type writableKeyEcho struct { + UseCustomEchoVoice *bool `toml:"custom-voice,omitempty"` + UseCustomEchoSpeechServer *bool `toml:"custom-speech-server,omitempty"` + UseCustomEchoForKey *bool `toml:"custom-for-key,omitempty"` + UseCustomEchoForCharacter *bool `toml:"custom-for-character,omitempty"` + UseCustomEchoForWord *bool `toml:"custom-for-word,omitempty"` + UseCustomEchoForSentence *bool `toml:"custom-for-sentence,omitempty"` + EchoVoice *Voice `toml:"voice,omitempty"` + EchoSpeechServer *string `toml:"speech-server,omitempty"` + + Enabled *bool `toml:"enabled,omitempty"` + ByCharacter *bool `toml:"by-character,omitempty"` + ByWord *bool `toml:"by-word,omitempty"` + BySentence *bool `toml:"by-sentence,omitempty"` + Alphabetic *bool `toml:"alphabetic,omitempty"` + Numeric *bool `toml:"numeric,omitempty"` + Punctuation *bool `toml:"punctuation,omitempty"` + Space *bool `toml:"space,omitempty"` + Modifiers *bool `toml:"modifiers,omitempty"` + FunctionKeys *bool `toml:"function-keys,omitempty"` + ActionKeys *bool `toml:"action-keys,omitempty"` + NavigationKeys *bool `toml:"navigation-keys,omitempty"` + Diacritical *bool `toml:"diacritical,omitempty"` +} diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..fb7c19d --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,212 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "git.stormux.org/storm/cthulhu/internal/input" +) + +type VoiceFamily struct { + Name string `toml:"name"` + Lang string `toml:"lang"` + Dialect string `toml:"dialect"` + Variant string `toml:"variant"` +} + +type Voice struct { + Rate *float64 `toml:"rate"` + AveragePitch *float64 `toml:"average-pitch"` + Gain *float64 `toml:"gain"` + Family *VoiceFamily `toml:"family"` +} + +type ReadingSettings struct { + AutomaticallyReadClipboardChanges bool + AutomaticallyReadTitleChanges bool + EnablePauseBreaks bool + SpeakNumbersAsDigits bool + SpeakMultiCaseStringsAsWords bool + EnableIndentation bool + IndentationPresentationMode string + OnlySpeakDisplayedText bool + SpeakBlankLines bool + EnableMnemonicSpeaking bool + EnablePositionSpeaking bool + EnableTutorialMessages bool + MessagesAreDetailed bool + UseColorNames bool + PresentTooltips bool + EnableMouseReview bool + SpeakMisspelledIndicator bool + SpeakContextBlockquote bool + SpeakContextList bool + SpeakContextTable bool + SpeakContextPanel bool + SpeakContextLandmark bool + SpeakContextNonLandmarkForm bool + SayAllContextBlockquote bool + SayAllContextList bool + SayAllContextTable bool + SayAllContextPanel bool + SayAllContextLandmark bool + SayAllContextNonLandmarkForm bool + ReadFullRowInGUITable bool + ReadFullRowInDocumentTable bool + ReadFullRowInSpreadSheet bool +} + +type PronunciationEntry struct { + Word string `toml:"word"` + Replacement string `toml:"replacement"` +} + +type TextAttributeChoice struct { + Key string `toml:"key"` + Speak bool `toml:"speak"` + Braille bool `toml:"braille"` +} + +type TextAttributeSettings struct { + Choices []TextAttributeChoice + Indicator int +} + +var TextAttributeKeys = [...]string{ + "size", "family-name", "weight", "indent", "underline", "strikethrough", + "justification", "style", "paragraph-style", "text-spelling", "fg-color", "bg-color", +} + +func DefaultTextAttributes() TextAttributeSettings { + choices := make([]TextAttributeChoice, 0, len(TextAttributeKeys)) + for _, key := range TextAttributeKeys { + choices = append(choices, TextAttributeChoice{Key: key, Speak: true}) + } + return TextAttributeSettings{Choices: choices} +} + +// ProgressBarSettings follows NVDA's output modes and percentage thresholds. +type ProgressBarSettings struct { + OutputMode string + ReportBackground bool + BeepPercentageInterval int + SpeechPercentageInterval int +} + +func DefaultProgressBarSettings() ProgressBarSettings { + return ProgressBarSettings{OutputMode: "beep", BeepPercentageInterval: 1, SpeechPercentageInterval: 10} +} + +type Settings struct { + OCR OCRSettings + ProgressBar ProgressBarSettings + TimeFormat string + DateFormat string + EnableSound bool + SoundVolume float64 + SoundSink string + SoundTheme string + PlaySoundForRole bool + PlaySoundForState bool + PlaySoundForPositionInSet bool + SpatializeObjectSounds bool + RoleSoundPresentation string + + Path string + Loaded bool + ActiveProfile string + EnableSpeech bool + SpeechHistoryLimit int + EnableBraille bool + EnableContractedBraille bool + BrailleContractionTable string + Braille BrailleOptions + HardwareSpeechDevice string + HardwareSpeechBaudRate int + SpeechServerFactory string + SpeakDescription bool + SpeechVerbosityLevel int + SayAllStyle string + Reading ReadingSettings + Pronunciations []PronunciationEntry + TextAttributes TextAttributeSettings + VerbalizePunctuationStyle int + CapitalizationStyle string + KeyBindings []input.BindingOverride + KeyboardLayout int + CthulhuModifierKeys []string + EnableKeyEcho bool + EnableEchoByCharacter bool + EnableEchoByWord bool + EnableEchoBySentence bool + EnableAlphabeticKeys bool + EnableNumericKeys bool + EnablePunctuationKeys bool + EnableSpace bool + EnableModifierKeys bool + EnableFunctionKeys bool + EnableActionKeys bool + EnableNavigationKeys bool + EnableDiacriticalKeys bool + UseCustomEchoVoice bool + UseCustomEchoSpeechServer bool + UseCustomEchoForKey bool + UseCustomEchoForCharacter bool + UseCustomEchoForWord bool + UseCustomEchoForSentence bool + EchoVoice Voice + EchoSpeechServer string + SpeechOutputModule string + DefaultVoice Voice + UppercaseVoice Voice + HyperlinkVoice Voice + SystemVoice Voice +} + +type OCRSettings struct { + Language string + Layout int + Scale int + Grayscale bool + Invert bool + BlackWhite bool + Threshold int + AutoRefresh bool + RefreshInterval int // Milliseconds. + ConfirmClick bool + CopyFirstResult bool + AutoReadAll bool +} + +const ( + DefaultSpeechHistoryLimit = 50 + MinSpeechHistoryLimit = 1 + MaxSpeechHistoryLimit = 1000 +) + +type BrailleOptions struct { + AbbreviateRoles bool + DisableEOL bool + WordWrap bool + Verbosity int + SelectionIndicator int + LinkIndicator int + FlashMessages bool + FlashTime int + FlashPersistent bool + FlashDetailed bool +} diff --git a/internal/config/settings_copy.go b/internal/config/settings_copy.go new file mode 100644 index 0000000..86383d0 --- /dev/null +++ b/internal/config/settings_copy.go @@ -0,0 +1,56 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "git.stormux.org/storm/cthulhu/internal/input" +) + +func cloneSettings(settings Settings) Settings { + settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings) + settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...) + settings.Pronunciations = append([]PronunciationEntry(nil), settings.Pronunciations...) + settings.TextAttributes.Choices = append([]TextAttributeChoice(nil), settings.TextAttributes.Choices...) + settings.DefaultVoice = cloneVoice(settings.DefaultVoice) + settings.UppercaseVoice = cloneVoice(settings.UppercaseVoice) + settings.HyperlinkVoice = cloneVoice(settings.HyperlinkVoice) + settings.SystemVoice = cloneVoice(settings.SystemVoice) + settings.EchoVoice = cloneVoice(settings.EchoVoice) + return settings +} + +func cloneVoice(voice Voice) Voice { + clone := voice + if voice.Rate != nil { + value := *voice.Rate + clone.Rate = &value + } + if voice.AveragePitch != nil { + value := *voice.AveragePitch + clone.AveragePitch = &value + } + if voice.Gain != nil { + value := *voice.Gain + clone.Gain = &value + } + if voice.Family != nil { + family := *voice.Family + clone.Family = &family + } + return clone +} diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000..bf51fb9 --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,285 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "syscall" +) + +// Store owns the versioned Go configuration. An explicitly selected +// Python-era file fails strict decoding and cannot be replaced by Save. +type Store struct { + ioMu sync.Mutex + mu sync.RWMutex + path string + lastGood Settings + rename func(string, string) error +} + +func DefaultStorePath() (string, error) { return DefaultPath() } + +func NewStore(path string) (*Store, error) { + if path == "" { + var err error + path, err = DefaultStorePath() + if err != nil { + return nil, err + } + } + defaults := DefaultSettings() + defaults.Path = path + return &Store{path: path, lastGood: cloneSettings(defaults), rename: os.Rename}, nil +} + +func (store *Store) Path() string { return store.path } + +// Current returns a copy of the last configuration which parsed and validated. +func (store *Store) Current() Settings { + store.mu.RLock() + defer store.mu.RUnlock() + return cloneSettings(store.lastGood) +} + +// Load returns the last known good configuration together with any read, +// parse, schema, or validation error. A missing file selects built-in defaults. +func (store *Store) Load() (Settings, error) { + store.ioMu.Lock() + defer store.ioMu.Unlock() + contents, err := readStoreFile(store.path) + if errors.Is(err, os.ErrNotExist) { + defaults := DefaultSettings() + defaults.Path = store.path + store.setCurrent(defaults) + return cloneSettings(defaults), nil + } + if err != nil { + return store.Current(), err + } + _, settings, err := decodeDocument(contents) + if err != nil { + return store.Current(), err + } + settings.Path = store.path + settings.Loaded = true + store.setCurrent(settings) + return cloneSettings(settings), nil +} + +// Save updates only the active profile and preserves all other profiles and +// application mappings. It validates before using an atomic replacement. +func (store *Store) Save(settings Settings) error { + return store.save(settings, "") +} + +// LoadForApplication returns the selected application profile, or the global +// profile when no application settings have been saved. +func (store *Store) LoadForApplication(application string) (Settings, bool, error) { + store.ioMu.Lock() + defer store.ioMu.Unlock() + document, _, err := store.documentForSave() + if err != nil { + return Settings{}, false, err + } + global, err := effectiveSettings(document) + if err != nil { + return Settings{}, false, err + } + name, found := document.Applications[application] + if found { + applyProfile(&global, document.Profiles[name]) + global.ActiveProfile = name + } + global.Path, global.Loaded = store.path, true + return global, found, nil +} + +// SaveForApplication creates or updates a dedicated profile without changing +// the global profile or other application mappings. +func (store *Store) SaveForApplication(application string, settings Settings) error { + if application == "" || application != strings.TrimSpace(application) { + return errors.New("invalid application identifier") + } + if err := validateCommandValue("application identifier", application); err != nil { + return err + } + return store.save(settings, application) +} + +func (store *Store) save(settings Settings, application string) error { + store.ioMu.Lock() + defer store.ioMu.Unlock() + if err := validate(settings); err != nil { + return err + } + document, original, err := store.documentForSave() + if err != nil { + return err + } + global, err := effectiveSettings(document) + if err != nil { + return err + } + profileName := document.ActiveProfile + if application == "" { + if settings.ActiveProfile != "" { + profileName = settings.ActiveProfile + } + if _, exists := document.Profiles[profileName]; !exists { + return fmt.Errorf("active profile %q does not exist", profileName) + } + } else { + profileName = document.Applications[application] + if profileName == "" { + hash := sha256.Sum256([]byte(application)) + profileName = "application-" + hex.EncodeToString(hash[:]) + } + if document.Applications == nil { + document.Applications = make(map[string]string) + } + document.Applications[application] = profileName + } + profile := profileFromSettings(settings) + if application != "" { + profile = applicationProfileDifference(global, settings) + } + document.Profiles[profileName] = profile + contents, err := encodeDocument(document) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil { + return fmt.Errorf("create settings directory: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(store.path), ".settings-*.toml") + if err != nil { + return fmt.Errorf("create temporary settings file: %w", err) + } + temporaryPath := temporary.Name() + keepTemporary := false + defer func() { + if !keepTemporary { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return fmt.Errorf("restrict temporary settings file: %w", err) + } + if _, err := temporary.Write(contents); err != nil { + _ = temporary.Close() + return fmt.Errorf("write temporary settings file: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("flush temporary settings file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary settings file: %w", err) + } + if err := ensureUnchanged(store.path, original); err != nil { + return err + } + if err := store.rename(temporaryPath, store.path); err != nil { + return fmt.Errorf("replace settings file: %w", err) + } + keepTemporary = true + saved, err := effectiveSettings(document) + if err != nil { + return err + } + saved.Path = store.path + saved.Loaded = true + store.setCurrent(saved) + if err := syncDirectory(filepath.Dir(store.path)); err != nil { + return fmt.Errorf("flush settings directory: %w", err) + } + return nil +} + +func (store *Store) documentForSave() (writableDocument, []byte, error) { + contents, err := readStoreFile(store.path) + if errors.Is(err, os.ErrNotExist) { + return defaultDocument(), nil, nil + } + if err != nil { + return writableDocument{}, nil, err + } + document, _, err := decodeDocument(contents) + if err != nil { + return writableDocument{}, nil, fmt.Errorf("refuse to replace existing settings without a successful versioned load: %w", err) + } + return document, contents, nil +} + +func ensureUnchanged(path string, original []byte) error { + current, err := readStoreFile(path) + if original == nil && errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("verify settings before replacement: %w", err) + } + if original == nil || !bytes.Equal(current, original) { + return errors.New("settings changed while being saved; no file was replaced") + } + return nil +} + +func readStoreFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + contents, err := io.ReadAll(io.LimitReader(file, maxConfigSize+1)) + if err != nil { + return nil, fmt.Errorf("read settings: %w", err) + } + if len(contents) > maxConfigSize { + return nil, fmt.Errorf("settings exceed %d bytes", maxConfigSize) + } + return contents, nil +} + +func (store *Store) setCurrent(settings Settings) { + store.mu.Lock() + defer store.mu.Unlock() + store.lastGood = cloneSettings(settings) +} + +func syncDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + defer directory.Close() + if err := directory.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) && !errors.Is(err, syscall.ENOTSUP) { + return err + } + return nil +} diff --git a/internal/config/store_test.go b/internal/config/store_test.go new file mode 100644 index 0000000..8c46d1f --- /dev/null +++ b/internal/config/store_test.go @@ -0,0 +1,387 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +package config + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestStoreSaveAndLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "cthulhu", "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings := DefaultSettings() + settings.EnableSpeech = false + settings.SpeechHistoryLimit = 1000 + settings.SayAllStyle = "sentence" + settings.Reading.EnableMnemonicSpeaking = false + settings.Reading.SayAllContextList = false + settings.Reading.SayAllContextPanel = false + settings.Reading.PresentTooltips = true + settings.Reading.EnableMouseReview = true + settings.EnableBraille = true + settings.EnableContractedBraille = true + settings.BrailleContractionTable = "en-us-g2.ctb" + settings.Braille = BrailleOptions{AbbreviateRoles: true, DisableEOL: true, WordWrap: true, + Verbosity: 0, SelectionIndicator: 0x40, LinkIndicator: 0x80, + FlashMessages: true, FlashTime: 3200, FlashPersistent: true, FlashDetailed: false} + settings.VerbalizePunctuationStyle = 3 + settings.CthulhuModifierKeys = []string{"Insert"} + rate := 72.0 + settings.DefaultVoice.Rate = &rate + if err := store.Save(settings); err != nil { + t.Fatalf("Save: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if permissions := info.Mode().Perm(); permissions != 0o600 { + t.Fatalf("settings permissions = %04o, want 0600", permissions) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), "schema-version = 2") || + !strings.Contains(string(contents), "[profiles.default.speech]") || + !strings.Contains(string(contents), "history-limit = 1000") { + t.Fatalf("settings do not contain schema version:\n%s", contents) + } + + reopened, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + loaded, err := reopened.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + settings.Path, settings.Loaded, settings.ActiveProfile = path, true, "default" + if !reflect.DeepEqual(loaded, settings) { + t.Fatalf("loaded settings differ:\n got: %#v\nwant: %#v", loaded, settings) + } +} + +func TestLoadUpgradesLegacyDateFormatIDs(t *testing.T) { + for source, target := range map[string]string{ + "ymd": "numbers-ymd", + "mdy": "numbers-mdy", + "dmy": "numbers-dmy", + } { + path := filepath.Join(t.TempDir(), "settings.toml") + contents := "schema-version = 2\nactive-profile = 'default'\n[profiles.default.presentation]\ntime-format = '24-hour'\ndate-format = '" + source + "'\n" + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + settings, err := Load(path) + if err != nil { + t.Fatalf("%s: Load: %v", source, err) + } + if settings.TimeFormat != "24-hour" || settings.DateFormat != target { + t.Fatalf("%s: formats = (%s, %s), want (24-hour, %s)", source, + settings.TimeFormat, settings.DateFormat, target) + } + } +} + +func TestStoreMissingFileUsesDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings, err := store.Load() + if err != nil { + t.Fatal(err) + } + if settings.Loaded || settings.Path != path || !settings.EnableSpeech || + settings.SpeechHistoryLimit != DefaultSpeechHistoryLimit { + t.Fatalf("missing settings = %#v", settings) + } +} + +func TestStoreRejectsSchemaVersionsAndUnknownFields(t *testing.T) { + for name, contents := range map[string]string{ + "missing": `[general]`, + "future": "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n", + "unknown": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\nfuture-setting = true\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err == nil { + t.Fatal("Load accepted incompatible settings") + } + if !reflect.DeepEqual(loaded, store.Current()) || loaded.Loaded { + t.Fatalf("last good settings were not retained: %#v", loaded) + } + }) + } +} + +func TestStoreVersionFixtures(t *testing.T) { + current, err := os.ReadFile(filepath.Join("testdata", "settings-v2.toml")) + if err != nil { + t.Fatal(err) + } + settings, err := decodeWritable(current) + if err != nil { + t.Fatalf("decode current fixture: %v", err) + } + if settings.SpeakDescription || settings.VerbalizePunctuationStyle != 2 || settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 65 { + t.Fatalf("current fixture settings = %#v", settings) + } + + future, err := os.ReadFile(filepath.Join("testdata", "settings-v3.toml")) + if err != nil { + t.Fatal(err) + } + if _, err := decodeWritable(future); err == nil || !strings.Contains(err.Error(), "schema version 3") { + t.Fatalf("future fixture error = %v", err) + } +} + +func TestStoreLoadFailureRetainsLastGood(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings := DefaultSettings() + settings.SpeakDescription = false + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + good := store.Current() + if err := os.WriteFile(path, []byte("schema-version = 2\n[profiles\n"), 0o600); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err == nil { + t.Fatal("Load accepted malformed settings") + } + if !reflect.DeepEqual(loaded, good) || !reflect.DeepEqual(store.Current(), good) { + t.Fatalf("last good changed:\n got: %#v\nwant: %#v", loaded, good) + } +} + +func TestStoreValidationFailureDoesNotTouchFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings := DefaultSettings() + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + settings.SoundVolume = 2 + if err := store.Save(settings); err == nil { + t.Fatal("Save accepted invalid settings") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, original) { + t.Fatal("validation failure changed the settings file") + } +} + +func TestStoreRefusesToReplaceLegacyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "user-settings.toml") + legacy := []byte("[general]\nenableSpeech = false\nunknownPythonSetting = true\n") + if err := os.WriteFile(path, legacy, 0o600); err != nil { + t.Fatal(err) + } + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + if err := store.Save(DefaultSettings()); err == nil || !strings.Contains(err.Error(), "refuse to replace") { + t.Fatalf("Save error = %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, legacy) { + t.Fatal("legacy file changed") + } +} + +func TestDefaultStorePathUsesGoDevelopmentSettingsPath(t *testing.T) { + t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data")) + path, err := DefaultStorePath() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "user-settings-go.toml") + if path != want { + t.Fatalf("DefaultStorePath = %q, want %q", path, want) + } +} + +func TestStoreRenameFailurePreservesOriginalAndCleansTemporary(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings := DefaultSettings() + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + store.rename = func(_, _ string) error { return errors.New("injected rename failure") } + settings.EnableSpeech = false + if err := store.Save(settings); err == nil || !strings.Contains(err.Error(), "injected rename failure") { + t.Fatalf("Save error = %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, original) { + t.Fatal("rename failure changed the settings file") + } + temporary, err := filepath.Glob(filepath.Join(directory, ".settings-*.toml")) + if err != nil { + t.Fatal(err) + } + if len(temporary) != 0 { + t.Fatalf("temporary files remain: %v", temporary) + } +} + +func TestStoreCopiesMutableSettings(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) + if err != nil { + t.Fatal(err) + } + settings := DefaultSettings() + rate := 40.0 + settings.DefaultVoice.Rate = &rate + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + settings.CthulhuModifierKeys[0] = "changed" + rate = 99 + current := store.Current() + if current.CthulhuModifierKeys[0] != "Insert" || current.DefaultVoice.Rate == nil || *current.DefaultVoice.Rate != 40 { + t.Fatalf("stored settings alias caller memory: %#v", current) + } + current.CthulhuModifierKeys[0] = "changed again" + *current.DefaultVoice.Rate = 1 + again := store.Current() + if again.CthulhuModifierKeys[0] != "Insert" || *again.DefaultVoice.Rate != 40 { + t.Fatalf("Current aliases store memory: %#v", again) + } +} + +func TestStoreSavePreservesOtherProfilesAndApplicationMappings(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "user-settings-go.toml") + fixture, err := os.ReadFile(filepath.Join("testdata", "settings-v2.toml")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, fixture, 0o600); err != nil { + t.Fatal(err) + } + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + settings, err := store.Load() + if err != nil { + t.Fatal(err) + } + settings.VerbalizePunctuationStyle = 3 + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + document, loaded, err := decodeDocument(contents) + if err != nil { + t.Fatal(err) + } + if loaded.VerbalizePunctuationStyle != 3 || loaded.ActiveProfile != "default" { + t.Fatalf("saved active profile = %#v", loaded) + } + if document.Applications["org.example.Editor"] != "quiet" { + t.Fatalf("application mappings = %#v", document.Applications) + } + quiet := DefaultSettings() + applyProfile(&quiet, document.Profiles["quiet"]) + if quiet.EnableSpeech { + t.Fatal("save changed the unrelated quiet profile") + } +} + +func TestStoreRejectsInvalidProfileTopology(t *testing.T) { + for name, contents := range map[string]string{ + "missing active profile": "schema-version = 2\nactive-profile = 'missing'\n[profiles.default]\n", + "missing application profile": "schema-version = 2\nactive-profile = 'default'\n[applications]\n'org.example.Editor' = 'missing'\n[profiles.default]\n", + "invalid inactive profile": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\n[profiles.broken.sound]\nvolume = 2.0\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := decodeWritable([]byte(contents)); err == nil { + t.Fatal("invalid profile topology was accepted") + } + }) + } +} + +func TestEnsureUnchangedDetectsExternalCreationAndModification(t *testing.T) { + path := filepath.Join(t.TempDir(), "user-settings-go.toml") + if err := ensureUnchanged(path, nil); err != nil { + t.Fatalf("missing file changed unexpectedly: %v", err) + } + if err := os.WriteFile(path, []byte("created"), 0o600); err != nil { + t.Fatal(err) + } + if err := ensureUnchanged(path, nil); err == nil || !strings.Contains(err.Error(), "changed while being saved") { + t.Fatalf("external creation error = %v", err) + } + if err := ensureUnchanged(path, []byte("original")); err == nil || !strings.Contains(err.Error(), "changed while being saved") { + t.Fatalf("external modification error = %v", err) + } + if err := ensureUnchanged(path, []byte("created")); err != nil { + t.Fatalf("unchanged file rejected: %v", err) + } +} diff --git a/internal/config/testdata/settings-v1.toml b/internal/config/testdata/settings-v1.toml new file mode 100644 index 0000000..0923b6c --- /dev/null +++ b/internal/config/testdata/settings-v1.toml @@ -0,0 +1,34 @@ +schema-version = 1 + +[general] +enable-sound = true +sound-volume = 0.5 +sound-sink = "auto" +sound-theme = "default" +play-sound-for-role = false +play-sound-for-state = false +role-sound-presentation = "sound_and_speech" +enable-speech = true +speech-server-factory = "speechdispatcherfactory" +speak-description = false +speech-verbosity-level = 1 +verbalize-punctuation-style = 2 +capitalization-style = "none" +keyboard-layout = 1 +cthulhu-modifier-keys = ["Insert", "KP_Insert"] +enable-key-echo = true +enable-echo-by-character = false +enable-echo-by-word = false +enable-echo-by-sentence = false +enable-alphabetic-keys = true +enable-numeric-keys = true +enable-punctuation-keys = true +enable-space = true +enable-modifier-keys = true +enable-function-keys = true +enable-action-keys = true +enable-navigation-keys = false +enable-diacritical-keys = false + +[general.default-voice] +rate = 65.0 diff --git a/internal/config/testdata/settings-v2.toml b/internal/config/testdata/settings-v2.toml new file mode 100644 index 0000000..55e981c --- /dev/null +++ b/internal/config/testdata/settings-v2.toml @@ -0,0 +1,17 @@ +schema-version = 2 +active-profile = "default" + +[applications] +"org.example.Editor" = "quiet" + +[profiles.default.presentation] +speak-description = false + +[profiles.default.speech] +punctuation-style = 2 + +[profiles.default.speech.default-voice] +rate = 65.0 + +[profiles.quiet.speech] +enabled = false diff --git a/internal/config/testdata/settings-v3.toml b/internal/config/testdata/settings-v3.toml new file mode 100644 index 0000000..ba74edd --- /dev/null +++ b/internal/config/testdata/settings-v3.toml @@ -0,0 +1,4 @@ +schema-version = 3 +active-profile = "default" + +[profiles.default] diff --git a/internal/config/text_preferences_test.go b/internal/config/text_preferences_test.go new file mode 100644 index 0000000..246314a --- /dev/null +++ b/internal/config/text_preferences_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestTextPreferencesPersistAndInheritByApplication(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) + if err != nil { + t.Fatal(err) + } + global := DefaultSettings() + global.Pronunciations = []PronunciationEntry{{Word: "SQL", Replacement: "sequel"}} + global.TextAttributes.Choices[0].Speak = false + global.TextAttributes.Choices[0].Braille = true + global.TextAttributes.Indicator = 0x40 + if err := store.Save(global); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil || !reflect.DeepEqual(loaded.Pronunciations, global.Pronunciations) || !reflect.DeepEqual(loaded.TextAttributes, global.TextAttributes) { + t.Fatalf("loaded preferences = %+v, %+v, %v", loaded.Pronunciations, loaded.TextAttributes, err) + } + app := cloneSettings(global) + app.Pronunciations = []PronunciationEntry{{Word: "SQL", Replacement: "S Q L"}} + app.TextAttributes.Choices[0], app.TextAttributes.Choices[1] = app.TextAttributes.Choices[1], app.TextAttributes.Choices[0] + app.TextAttributes.Indicator = 0xc0 + if err := store.SaveForApplication("Editor", app); err != nil { + t.Fatal(err) + } + actual, found, err := store.LoadForApplication("Editor") + if err != nil || !found || !reflect.DeepEqual(actual.Pronunciations, app.Pronunciations) || !reflect.DeepEqual(actual.TextAttributes, app.TextAttributes) { + t.Fatalf("application preferences = %+v, %+v, %t, %v", actual.Pronunciations, actual.TextAttributes, found, err) + } +} diff --git a/internal/config/validation.go b/internal/config/validation.go new file mode 100644 index 0000000..0cd9e2f --- /dev/null +++ b/internal/config/validation.go @@ -0,0 +1,259 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package config + +import ( + "errors" + "fmt" + "math" + "path/filepath" + "strings" + "unicode" + + "git.stormux.org/storm/cthulhu/internal/datetime" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// Validate checks the complete configuration without touching the filesystem. +func Validate(settings Settings) error { return validate(settings) } + +func validate(settings Settings) error { + if settings.OCR.Layout != 3 && settings.OCR.Layout != 6 && settings.OCR.Layout != 11 || + settings.OCR.Scale < 1 || settings.OCR.Scale > 4 || settings.OCR.Threshold < 0 || settings.OCR.Threshold > 255 || + settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 || + len(settings.OCR.Language) > 1024 || strings.ContainsAny(settings.OCR.Language, "\x00\r\n\t ") { + return errors.New("validate configuration: invalid OCR settings") + } + if settings.Braille.Verbosity < 0 || settings.Braille.Verbosity > 1 || + settings.Braille.FlashTime < 0 || settings.Braille.FlashTime > 60000 { + return errors.New("validate configuration: invalid braille settings") + } + if len(settings.Pronunciations) > 256 { + return errors.New("too many pronunciation entries") + } + words := make(map[string]bool, len(settings.Pronunciations)) + for _, entry := range settings.Pronunciations { + word := strings.ToLower(entry.Word) + if word == "" || strings.TrimSpace(entry.Word) != entry.Word || + len(entry.Word) > 128 || len(entry.Replacement) > 512 || + strings.ContainsAny(entry.Word, "\x00\r\n\t ") || + strings.ContainsAny(entry.Replacement, "\x00\r\n") || words[word] { + return errors.New("pronunciation entries need unique, single words and valid replacements") + } + words[word] = true + for _, character := range entry.Word { + if !unicode.IsLetter(character) && !unicode.IsNumber(character) && character != '_' { + return errors.New("pronunciation words may contain only letters, numbers, or underscores") + } + } + } + if settings.TextAttributes.Indicator != 0 && settings.TextAttributes.Indicator != 0x40 && + settings.TextAttributes.Indicator != 0x80 && settings.TextAttributes.Indicator != 0xc0 { + return errors.New("invalid text attribute braille indicator") + } + if len(settings.TextAttributes.Choices) != len(TextAttributeKeys) { + return errors.New("text attributes list must include every supported attribute") + } + allowed, seen := make(map[string]bool), make(map[string]bool) + for _, key := range TextAttributeKeys { + allowed[key] = true + } + for _, choice := range settings.TextAttributes.Choices { + if !allowed[choice.Key] || seen[choice.Key] { + return errors.New("invalid or duplicate text attribute") + } + seen[choice.Key] = true + } + for _, indicator := range []int{settings.Braille.SelectionIndicator, settings.Braille.LinkIndicator} { + if indicator != 0 && indicator != 0x40 && indicator != 0x80 && indicator != 0xc0 { + return errors.New("validate configuration: invalid braille indicator") + } + } + if len(settings.BrailleContractionTable) > 256 || strings.ContainsAny(settings.BrailleContractionTable, "\x00\r\n,") { + return errors.New("validate configuration: invalid braille contraction table") + } + if !datetime.ValidTime(settings.TimeFormat) { + return errors.New("choose a valid time format") + } + if !datetime.ValidDate(settings.DateFormat) { + return errors.New("choose a valid date format") + } + if settings.SoundVolume < 0 || settings.SoundVolume > 1 || math.IsNaN(settings.SoundVolume) || math.IsInf(settings.SoundVolume, 0) { + return errors.New("validate configuration: soundVolume must be between 0 and 1") + } + switch settings.SoundSink { + case "auto", "pipewire", "pulse", "alsa": + default: + return fmt.Errorf("validate configuration: unknown soundSink %q", settings.SoundSink) + } + if settings.SoundTheme == "" || filepath.Base(settings.SoundTheme) != settings.SoundTheme || settings.SoundTheme == "." || settings.SoundTheme == ".." { + return errors.New("validate configuration: invalid soundTheme") + } + switch settings.ProgressBar.OutputMode { + case "off", "beep", "speak", "both": + default: + return errors.New("choose a valid progress bar output mode") + } + if settings.ProgressBar.BeepPercentageInterval < 1 || settings.ProgressBar.BeepPercentageInterval > 100 || + settings.ProgressBar.SpeechPercentageInterval < 1 || settings.ProgressBar.SpeechPercentageInterval > 100 { + return errors.New("progress bar percentage intervals must be between 1 and 100") + } + switch settings.RoleSoundPresentation { + case "sound_and_speech", "speech_only", "sound_only": + default: + return errors.New("validate configuration: invalid roleSoundPresentation") + } + + if settings.SpeechServerFactory != "speechdispatcherfactory" && + settings.SpeechServerFactory != "cthulhu.speechdispatcherfactory" && + settings.SpeechServerFactory != "hardwarefactory" { + return fmt.Errorf( + "validate configuration: speechServerFactory %q is not supported by the preview", + settings.SpeechServerFactory, + ) + } + if len(settings.HardwareSpeechDevice) > 1024 || strings.ContainsAny(settings.HardwareSpeechDevice, "\x00\r\n") { + return errors.New("invalid hardware speech device") + } + switch settings.HardwareSpeechBaudRate { + case 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200: + default: + return errors.New("choose a supported hardware speech baud rate") + } + if settings.SpeechServerFactory == "hardwarefactory" { + if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer { + return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable that option for hardware speech") + } + switch settings.SpeechOutputModule { + case "litetalk", "doubletalk", "tripletalk", "dectalk": + default: + return errors.New("choose a hardware speech synthesizer") + } + if !filepath.IsAbs(settings.HardwareSpeechDevice) { + return errors.New("choose an explicit serial device path for hardware speech") + } + } + switch settings.SpeechVerbosityLevel { + case 0, 1: + default: + return fmt.Errorf("validate configuration: speechVerbosityLevel %d is not 0 or 1", settings.SpeechVerbosityLevel) + } + if settings.SayAllStyle != "line" && settings.SayAllStyle != "sentence" { + return fmt.Errorf("validate configuration: say-all-style %q is invalid", settings.SayAllStyle) + } + if settings.SpeechHistoryLimit < MinSpeechHistoryLimit || settings.SpeechHistoryLimit > MaxSpeechHistoryLimit { + return fmt.Errorf("validate configuration: speech history limit %d must be between %d and %d", + settings.SpeechHistoryLimit, MinSpeechHistoryLimit, MaxSpeechHistoryLimit) + } + if settings.VerbalizePunctuationStyle < 0 || settings.VerbalizePunctuationStyle > 3 { + return fmt.Errorf( + "validate configuration: verbalizePunctuationStyle %d is outside 0 through 3", + settings.VerbalizePunctuationStyle, + ) + } + switch settings.CapitalizationStyle { + case "none", "spell", "icon": + default: + return fmt.Errorf("validate configuration: capitalizationStyle %q is invalid", settings.CapitalizationStyle) + } + if err := input.ValidateBindingOverrides(settings.KeyBindings); err != nil { + return fmt.Errorf("key bindings: %w", err) + } + if settings.KeyboardLayout != 1 && settings.KeyboardLayout != 2 { + return fmt.Errorf("validate configuration: keyboardLayout %d is not 1 or 2", settings.KeyboardLayout) + } + for _, key := range settings.CthulhuModifierKeys { + if err := validateCommandValue("cthulhuModifierKeys", key); err != nil { + return err + } + } + if strings.ContainsAny(settings.SpeechOutputModule, "\r\n\x00") { + return errors.New("invalid speech output module") + } + if strings.ContainsAny(settings.EchoSpeechServer, "\r\n\x00") { + return fmt.Errorf("invalid echo speech synthesizer") + } + if err := validateVoice(settings.EchoVoice, true); err != nil { + return fmt.Errorf("echo voice: %w", err) + } + if err := validateVoice(settings.DefaultVoice); err != nil { + return err + } + for _, voice := range []Voice{settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice} { + if err := validateVoice(voice); err != nil { + return err + } + } + switch settings.Reading.IndentationPresentationMode { + case "", "speech", "beeps", "both": + default: + return errors.New("invalid indentation presentation mode") + } + return nil +} + +func validateVoice(voice Voice, echo ...bool) error { + values := []struct { + name string + value *float64 + maximum float64 + }{ + {name: "rate", value: voice.Rate, maximum: 99}, + {name: "average-pitch", value: voice.AveragePitch, maximum: 9}, + {name: "gain", value: voice.Gain, maximum: 9}, + } + if len(echo) != 0 && echo[0] { + values[0].maximum = 100 + values[1].maximum = 10 + values[2].maximum = 10 + } + for _, field := range values { + name, value, maximum := field.name, field.value, field.maximum + if value == nil { + continue + } + if *value < 0 || *value > maximum || math.IsNaN(*value) || math.IsInf(*value, 0) { + return fmt.Errorf("validate configuration: default voice %s %.2f is outside 0 through %.0f", name, *value, maximum) + } + } + if voice.Family == nil { + return nil + } + familyValues := []struct { + name string + value string + }{ + {name: "name", value: voice.Family.Name}, + {name: "lang", value: voice.Family.Lang}, + {name: "dialect", value: voice.Family.Dialect}, + {name: "variant", value: voice.Family.Variant}, + } + for _, field := range familyValues { + if err := validateCommandValue("default voice family "+field.name, field.value); err != nil { + return err + } + } + return nil +} + +func validateCommandValue(name, value string) error { + if strings.ContainsAny(value, "\r\n\x00") { + return fmt.Errorf("validate configuration: %s contains a control character", name) + } + return nil +} diff --git a/internal/config/voices.go b/internal/config/voices.go new file mode 100644 index 0000000..27aa6f0 --- /dev/null +++ b/internal/config/voices.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +// EffectiveVoice resolves a sparse voice over Default. Uppercase retains the +// established pitch of 7 unless the user explicitly chooses another pitch. +func EffectiveVoice(settings Settings, kind string) Voice { + voice := cloneVoice(settings.DefaultVoice) + var override Voice + switch kind { + case "uppercase": + pitch := 7.0 + if voice.AveragePitch != nil { + pitch = min(9, max(pitch, *voice.AveragePitch+2)) + } + voice.AveragePitch = &pitch + override = settings.UppercaseVoice + case "hyperlink": + override = settings.HyperlinkVoice + case "system": + override = settings.SystemVoice + } + mergeVoice(&voice, cloneVoice(override)) + return voice +} diff --git a/internal/config/voices_test.go b/internal/config/voices_test.go new file mode 100644 index 0000000..fc4b3fb --- /dev/null +++ b/internal/config/voices_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestVoiceTypesPersistAndInheritAcrossApplications(t *testing.T) { + settings := DefaultSettings() + rate, pitch, gain := 62.0, 8.0, 4.0 + settings.DefaultVoice = Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain, Family: &VoiceFamily{Name: "Default person", Lang: "en"}} + upper, link, system := 8.5, 3.0, 2.0 + settings.UppercaseVoice.AveragePitch = &upper + settings.HyperlinkVoice.Rate = &link + settings.SystemVoice.Gain = &system + settings.Reading.IndentationPresentationMode = "both" + store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) + if err != nil { + t.Fatal(err) + } + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"uppercase", "hyperlink", "system"} { + if !reflect.DeepEqual(EffectiveVoice(loaded, kind), EffectiveVoice(settings, kind)) { + t.Fatalf("%s did not persist", kind) + } + } + if loaded.Reading.IndentationPresentationMode != "both" { + t.Fatal("indentation mode lost") + } + appSettings := loaded + appPitch := 5.0 + appSettings.UppercaseVoice.AveragePitch = &appPitch + if err := store.SaveForApplication("example-app", appSettings); err != nil { + t.Fatal(err) + } + application, found, err := store.LoadForApplication("example-app") + if err != nil || !found || *EffectiveVoice(application, "uppercase").AveragePitch != 5 { + t.Fatalf("application voice = %+v, %v", application.UppercaseVoice, err) + } + global, err := store.Load() + if err != nil || *EffectiveVoice(global, "uppercase").AveragePitch != 8.5 { + t.Fatal("application voice changed global voice") + } + *store.Current().UppercaseVoice.AveragePitch = 0 + if *store.Current().UppercaseVoice.AveragePitch != 8.5 { + t.Fatal("voice snapshots alias") + } + appSettings.UppercaseVoice = Voice{} + if err := store.SaveForApplication("example-app", appSettings); err != nil { + t.Fatal(err) + } + application, found, err = store.LoadForApplication("example-app") + if err != nil || !found || application.UppercaseVoice.AveragePitch != nil || *EffectiveVoice(application, "uppercase").AveragePitch != 9 { + t.Fatalf("application automatic pitch reset = %+v, %v", application.UppercaseVoice, err) + } +} + +func TestAutomaticUppercasePitchAndExplicitOverride(t *testing.T) { + settings := DefaultSettings() + for _, tc := range []struct{ normal, upper float64 }{{3, 7}, {5, 7}, {7, 9}, {8, 9}, {9, 9}} { + settings.DefaultVoice.AveragePitch = &tc.normal + if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != tc.upper { + t.Fatalf("normal %g: uppercase %g, want %g", tc.normal, got, tc.upper) + } + } + chosen := 6.0 + settings.UppercaseVoice.AveragePitch = &chosen + if *EffectiveVoice(settings, "uppercase").AveragePitch != chosen { + t.Fatal("explicit uppercase pitch ignored") + } + settings.Reading.IndentationPresentationMode = "invalid" + if Validate(settings) == nil { + t.Fatal("invalid indentation mode accepted") + } +} diff --git a/internal/config/window_titles_test.go b/internal/config/window_titles_test.go new file mode 100644 index 0000000..0b43565 --- /dev/null +++ b/internal/config/window_titles_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTitleChangePreferenceDefaultsForExistingSettings(t *testing.T) { + settings, err := Load(filepath.Join("testdata", "settings-v2.toml")) + if err != nil { + t.Fatal(err) + } + if !settings.Reading.AutomaticallyReadTitleChanges { + t.Fatal("omitted title preference defaults off") + } +} + +func TestTitleChangePreferenceRoundTripAndInheritance(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + global := DefaultSettings() + if err = store.Save(global); err != nil { + t.Fatal(err) + } + application := global + application.Reading.AutomaticallyReadTitleChanges = false + if err = store.SaveForApplication("Noisy Application", application); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + _, loaded, err := decodeDocument(contents) + if err != nil || !loaded.Reading.AutomaticallyReadTitleChanges { + t.Fatalf("global = %t, %v", loaded.Reading.AutomaticallyReadTitleChanges, err) + } + restored, found, err := store.LoadForApplication("Noisy Application") + if err != nil || !found || restored.Reading.AutomaticallyReadTitleChanges { + t.Fatalf("override = %t, found %t, %v", restored.Reading.AutomaticallyReadTitleChanges, found, err) + } + global.Reading.AutomaticallyReadTitleChanges = false + if err = store.Save(global); err != nil { + t.Fatal(err) + } + inherited, _, err := store.LoadForApplication("Other Application") + if err != nil || inherited.Reading.AutomaticallyReadTitleChanges { + t.Fatalf("inheritance = %t, %v", inherited.Reading.AutomaticallyReadTitleChanges, err) + } +} diff --git a/internal/datetime/datetime.go b/internal/datetime/datetime.go new file mode 100644 index 0000000..dbbed08 --- /dev/null +++ b/internal/datetime/datetime.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package datetime + +/* +#include +#include +#include +*/ +import "C" + +import ( + "time" + "unsafe" +) + +type Option struct { + Value string + Label string +} + +var timeFormats = []struct { + Value string + Spec string +}{ + {"locale", "%X"}, + {"12-hour", "%I:%M %p"}, + {"12-hour-seconds", "%I:%M:%S %p"}, + {"24-hour-seconds", "%H:%M:%S"}, + {"24-hour-seconds-words", "%H hours, %M minutes and %S seconds"}, + {"24-hour", "%H:%M"}, + {"24-hour-words", "%H hours and %M minutes"}, +} + +var dateFormats = []struct { + Value string + Spec string +}{ + {"locale", "%x"}, + {"numbers-dm", "%d/%m"}, + {"numbers-md", "%m/%d"}, + {"numbers-dmy", "%d/%m/%Y"}, + {"numbers-mdy", "%m/%d/%Y"}, + {"numbers-ymd", "%Y/%m/%d"}, + {"full-dm", "%A, %-d %B"}, + {"full-md", "%A, %B %-d"}, + {"full-dmy", "%A, %-d %B, %Y"}, + {"full-mdy", "%A, %B %-d, %Y"}, + {"full-ymd", "%Y. %B %-d, %A"}, + {"abbreviated-dm", "%a, %-d %b"}, + {"abbreviated-md", "%a, %b %-d"}, + {"abbreviated-dmy", "%a, %-d %b, %Y"}, + {"abbreviated-mdy", "%a, %b %-d, %Y"}, + {"abbreviated-ymd", "%Y. %b %-d, %a"}, +} + +func init() { + locale := C.CString("") + defer C.free(unsafe.Pointer(locale)) + C.setlocale(C.LC_TIME, locale) +} + +func TimeOptions(now time.Time) []Option { + return options(timeFormats, now) +} + +func DateOptions(now time.Time) []Option { + return options(dateFormats, now) +} + +func options(formats []struct { + Value string + Spec string +}, now time.Time) []Option { + result := make([]Option, 0, len(formats)) + for _, item := range formats { + result = append(result, Option{Value: item.Value, Label: format(now, item.Spec)}) + } + return result +} + +func FormatTime(value string, now time.Time) (string, bool) { + return formatValue(timeFormats, value, now) +} + +func FormatDate(value string, now time.Time) (string, bool) { + return formatValue(dateFormats, value, now) +} + +func ValidTime(value string) bool { + return valid(timeFormats, value) +} + +func ValidDate(value string) bool { + return valid(dateFormats, value) +} + +func NormalizeDate(value string) string { + switch value { + case "ymd": + return "numbers-ymd" + case "mdy": + return "numbers-mdy" + case "dmy": + return "numbers-dmy" + default: + return value + } +} + +func formatValue(formats []struct { + Value string + Spec string +}, value string, now time.Time) (string, bool) { + for _, item := range formats { + if item.Value == value { + return format(now, item.Spec), true + } + } + return "", false +} + +func valid(formats []struct { + Value string + Spec string +}, value string) bool { + for _, item := range formats { + if item.Value == value { + return true + } + } + return false +} + +func format(now time.Time, spec string) string { + cSpec := C.CString(spec) + defer C.free(unsafe.Pointer(cSpec)) + tm := C.struct_tm{ + tm_year: C.int(now.Year() - 1900), + tm_mon: C.int(int(now.Month()) - 1), + tm_mday: C.int(now.Day()), + tm_hour: C.int(now.Hour()), + tm_min: C.int(now.Minute()), + tm_sec: C.int(now.Second()), + tm_wday: C.int(int(now.Weekday())), + tm_yday: C.int(now.YearDay() - 1), + tm_isdst: -1, + } + buffer := make([]C.char, 512) + length := C.strftime(&buffer[0], C.size_t(len(buffer)), cSpec, &tm) + return C.GoStringN(&buffer[0], C.int(length)) +} diff --git a/internal/input/bindings.go b/internal/input/bindings.go new file mode 100644 index 0000000..06ce6b9 --- /dev/null +++ b/internal/input/bindings.go @@ -0,0 +1,292 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// An override replaces all shortcuts for one command in one layout and scope. +// An empty Keys slice disables it; removing the override restores defaults. +type BindingOverride struct { + Layout KeyboardLayout `toml:"layout"` + Scope string `toml:"scope"` + Command Command `toml:"command"` + Keys []string `toml:"keys"` +} + +type BindingRow struct { + Scope string + Command Command + Label string + Keys string + DefaultKeys string +} + +func CloneBindingOverrides(overrides []BindingOverride) []BindingOverride { + if len(overrides) == 0 { + return nil + } + copy := append([]BindingOverride{}, overrides...) + for i := range copy { + copy[i].Keys = append([]string{}, overrides[i].Keys...) + } + return copy +} + +func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings { + global := DefaultCommandBindings(layout) + if scope == "global" { + return global + } + browse := BrowseCommandBindings(layout) + for chord := range global { + delete(browse, chord) + } + return browse +} + +func CommandLabel(command Command) string { + switch command { + case OCRObject: + return "Recognize object of interest" + case OCRWindow: + return "Recognize active window" + case OCRDesktop: + return "Recognize desktop" + case OCRClipboard: + return "Recognize clipboard image" + case OCRRefresh: + return "Refresh OCR result" + } + words := strings.Split(string(command), "-") + if len(words) > 0 && words[0] != "" { + words[0] = strings.ToUpper(words[0][:1]) + words[0][1:] + } + return strings.Join(words, " ") +} + +// ResolveCommandBindings removes replaced defaults before inserting any custom +// chords, allowing swaps while rejecting collisions with unchanged commands. +func ResolveCommandBindings(layout KeyboardLayout, browse bool, overrides []BindingOverride) (CommandBindings, error) { + global := bindingDefaults(layout, "global") + document := bindingDefaults(layout, "browse") + for _, override := range overrides { + if override.Layout != layout { + continue + } + bindings := global + if override.Scope == "browse" { + bindings = document + } + for chord, command := range bindings { + if command == override.Command { + delete(bindings, chord) + } + } + } + for _, override := range overrides { + if override.Layout != layout { + continue + } + bindings := global + if override.Scope == "browse" { + bindings = document + } + for _, key := range override.Keys { + chord, err := ParseCommandChord(key) + if err != nil { + return nil, err + } + if previous := bindings[chord]; previous != "" { + return nil, fmt.Errorf("%s conflicts with %s", key, CommandLabel(previous)) + } + bindings[chord] = override.Command + } + } + for chord, command := range document { + if previous := global[chord]; previous != "" { + return nil, fmt.Errorf("%s conflicts with %s in browse mode", FormatCommandChord(chord), CommandLabel(previous)) + } + if browse { + global[chord] = command + } + } + return global, nil +} + +func ValidateBindingOverrides(overrides []BindingOverride) error { + if len(overrides) > 256 { + return fmt.Errorf("too many key binding overrides") + } + seen := make(map[string]bool) + for _, override := range overrides { + if override.Layout != DesktopKeyboardLayout && override.Layout != LaptopKeyboardLayout { + return fmt.Errorf("invalid binding layout") + } + if override.Scope != "global" && override.Scope != "browse" { + return fmt.Errorf("invalid binding scope") + } + known := false + for _, row := range BindingRows(override.Layout, nil) { + known = known || row.Scope == override.Scope && row.Command == override.Command + } + if !known { + return fmt.Errorf("unknown %s command %q", override.Scope, override.Command) + } + id := fmt.Sprintf("%d/%s/%s", override.Layout, override.Scope, override.Command) + if seen[id] { + return fmt.Errorf("duplicate override for %s", CommandLabel(override.Command)) + } + seen[id] = true + if len(override.Keys) > 8 { + return fmt.Errorf("too many shortcuts for %s", CommandLabel(override.Command)) + } + } + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + if _, err := ResolveCommandBindings(layout, true, overrides); err != nil { + return err + } + } + return nil +} + +func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRow { + var rows []BindingRow + for _, scope := range []string{"global", "browse"} { + groups := make(map[Command][]string) + for chord, command := range bindingDefaults(layout, scope) { + groups[command] = append(groups[command], FormatCommandChord(chord)) + } + if scope == "global" { + for _, command := range []Command{PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} { + if _, exists := groups[command]; !exists { + groups[command] = nil + } + } + } + for command, keys := range groups { + sort.Strings(keys) + defaults := strings.Join(keys, "; ") + for _, override := range overrides { + if override.Layout == layout && override.Scope == scope && override.Command == command { + keys = override.Keys + break + } + } + rows = append(rows, BindingRow{scope, command, CommandLabel(command), strings.Join(keys, "; "), defaults}) + } + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Scope != rows[j].Scope { + return rows[i].Scope < rows[j].Scope + } + return rows[i].Label < rows[j].Label + }) + return rows +} + +var namedKeySymbols = map[string]uint32{ + "Space": 0x20, "Tab": 0xff09, "Return": 0xff0d, "Escape": 0xff1b, + "BackSpace": 0xff08, "Delete": 0xffff, "Home": 0xff50, "End": 0xff57, + "Left": 0xff51, "Up": 0xff52, "Right": 0xff53, "Down": 0xff54, + "Page_Up": 0xff55, "Page_Down": 0xff56, "Pause": 0xff13, "Print": 0xff61, + "KP_Enter": 0xff8d, "KP_Home": 0xff95, "KP_Left": 0xff96, "KP_Up": 0xff97, + "KP_Right": 0xff98, "KP_Down": 0xff99, "KP_Page_Up": 0xff9a, "KP_Page_Down": 0xff9b, + "KP_End": 0xff9c, "KP_Begin": 0xff9d, "KP_Delete": 0xff9f, + "KP_Multiply": 0xffaa, "KP_Add": 0xffab, "KP_Separator": 0xffac, + "KP_Subtract": 0xffad, "KP_Decimal": 0xffae, "KP_Divide": 0xffaf, + "semicolon": ';', "plus": '+', +} + +func ParseCommandChord(text string) (CommandChord, error) { + var chord CommandChord + if len(text) > 128 { + return chord, fmt.Errorf("shortcut is too long") + } + parts := strings.Split(strings.TrimSpace(text), "+") + for _, part := range parts[:len(parts)-1] { + switch strings.ToLower(strings.TrimSpace(part)) { + case "cthulhu": + if chord.CthulhuModifier { + return chord, fmt.Errorf("duplicate Cthulhu modifier") + } + chord.CthulhuModifier = true + case "ctrl", "control": + if chord.Modifiers&4 != 0 { + return chord, fmt.Errorf("duplicate Control modifier") + } + chord.Modifiers |= 4 + case "shift": + if chord.Modifiers&1 != 0 { + return chord, fmt.Errorf("duplicate Shift modifier") + } + chord.Modifiers |= 1 + case "alt": + if chord.Modifiers&8 != 0 { + return chord, fmt.Errorf("duplicate Alt modifier") + } + chord.Modifiers |= 8 + case "super": + if chord.Modifiers&64 != 0 { + return chord, fmt.Errorf("duplicate Super modifier") + } + chord.Modifiers |= 64 + default: + return chord, fmt.Errorf("unknown modifier %q", part) + } + } + name := strings.TrimSpace(parts[len(parts)-1]) + for key, symbol := range namedKeySymbols { + if strings.EqualFold(key, name) { + chord.Symbol = symbol + return chord, nil + } + } + if len(name) > 1 && (name[0] == 'F' || name[0] == 'f') { + if number, err := strconv.Atoi(name[1:]); err == nil && number >= 1 && number <= 35 { + chord.Symbol = uint32(0xffbd + number) + return chord, nil + } + } + if len(name) == 1 && name[0] >= 33 && name[0] <= 126 { + chord.Symbol = uint32(strings.ToLower(name)[0]) + return chord, nil + } + return chord, fmt.Errorf("unknown key %q; use a letter, function key, or named key such as Space", name) +} + +func FormatCommandChord(chord CommandChord) string { + var parts []string + if chord.CthulhuModifier { + parts = append(parts, "Cthulhu") + } + for _, modifier := range []struct { + mask uint16 + name string + }{{4, "Ctrl"}, {1, "Shift"}, {8, "Alt"}, {64, "Super"}} { + if chord.Modifiers&modifier.mask != 0 { + parts = append(parts, modifier.name) + } + } + name := "" + for key, symbol := range namedKeySymbols { + if symbol == chord.Symbol { + name = key + break + } + } + if name == "" { + if chord.Symbol >= 0xffbe && chord.Symbol <= 0xffe0 { + name = fmt.Sprintf("F%d", chord.Symbol-0xffbd) + } else { + name = string(rune(chord.Symbol)) + } + } + return strings.Join(append(parts, name), "+") +} diff --git a/internal/input/bindings_test.go b/internal/input/bindings_test.go new file mode 100644 index 0000000..31cef19 --- /dev/null +++ b/internal/input/bindings_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import ( + "reflect" + "testing" +) + +func TestBindingDefaultsRoundTrip(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for chord := range BrowseCommandBindings(layout) { + parsed, err := ParseCommandChord(FormatCommandChord(chord)) + if err != nil || parsed != chord { + t.Fatalf("%+v round trip: %+v %v", chord, parsed, err) + } + } + got, err := ResolveCommandBindings(layout, true, nil) + if err != nil || !reflect.DeepEqual(got, BrowseCommandBindings(layout)) { + t.Fatalf("default changed: %v", err) + } + } +} + +func TestBindingOverridesScopesLayoutsAndDisable(t *testing.T) { + overrides := []BindingOverride{ + {DesktopKeyboardLayout, "global", Preferences, []string{"Cthulhu+Ctrl+F8", "Alt+F9"}}, + {DesktopKeyboardLayout, "browse", DocumentNextHeading, []string{"F6"}}, + {DesktopKeyboardLayout, "global", Quit, []string{}}, + } + if err := ValidateBindingOverrides(overrides); err != nil { + t.Fatal(err) + } + for _, browse := range []bool{false, true} { + bindings, err := ResolveCommandBindings(DesktopKeyboardLayout, browse, overrides) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"Cthulhu+Space", "Cthulhu+q"} { + chord, _ := ParseCommandChord(key) + if bindings[chord] != "" { + t.Fatalf("old binding %s remains", key) + } + } + chord, _ := ParseCommandChord("Alt+F9") + if bindings[chord] != Preferences { + t.Fatal("custom global missing") + } + if bindings[CommandChord{Symbol: 'h', CthulhuModifier: true}] != ToggleLearnMode { + t.Fatal("learn mode shortcut lost") + } + chord, _ = ParseCommandChord("F6") + if (bindings[chord] == DocumentNextHeading) != browse || bindings[CommandChord{Symbol: 'h'}] != "" { + t.Fatal("browse scope leaked or old heading remains") + } + } + laptop, _ := ResolveCommandBindings(LaptopKeyboardLayout, false, overrides) + if !reflect.DeepEqual(laptop, DefaultCommandBindings(LaptopKeyboardLayout)) { + t.Fatal("desktop override changed laptop") + } + if !reflect.DeepEqual(CloneBindingOverrides(overrides), overrides) { + t.Fatal("clone changed overrides") + } + clone := CloneBindingOverrides(overrides) + clone[0].Keys[0] = "F1" + if overrides[0].Keys[0] == "F1" { + t.Fatal("clone aliases keys") + } +} + +func TestBindingValidation(t *testing.T) { + for _, overrides := range [][]BindingOverride{ + {{DesktopKeyboardLayout, "global", Preferences, []string{"Cthulhu+q"}}}, + {{DesktopKeyboardLayout, "global", Preferences, []string{"h"}}}, + {{DesktopKeyboardLayout, "browse", DocumentNextHeading, []string{"Cthulhu+q"}}}, + {{DesktopKeyboardLayout, "global", Preferences, []string{"F8", "f8"}}}, + {{DesktopKeyboardLayout, "global", Preferences, []string{"Insert"}}}, + {{DesktopKeyboardLayout, "global", Command("unknown"), []string{"F8"}}}, + {{KeyboardLayout(0), "global", Preferences, []string{"F8"}}}, + {{DesktopKeyboardLayout, "unknown", Preferences, []string{"F8"}}}, + {{DesktopKeyboardLayout, "global", Preferences, nil}, {DesktopKeyboardLayout, "global", Preferences, nil}}, + } { + if err := ValidateBindingOverrides(overrides); err == nil { + t.Fatalf("accepted %+v", overrides) + } + } + for _, key := range []string{"", "Ctrl+", "Ctrl+Ctrl+x", "Cthulhu+Cthulhu+x", "Hyper+x", "F36", "Ctrl+\n"} { + if _, err := ParseCommandChord(key); err == nil { + t.Fatalf("accepted %q", key) + } + } + swaps := []BindingOverride{{DesktopKeyboardLayout, "global", Quit, []string{"Cthulhu+Space"}}, {DesktopKeyboardLayout, "global", Preferences, []string{"Cthulhu+q"}}} + if err := ValidateBindingOverrides(swaps); err != nil { + t.Fatalf("swap: %v", err) + } + unbound := []BindingOverride{{DesktopKeyboardLayout, "global", PresentDate, []string{"F8"}}} + if err := ValidateBindingOverrides(unbound); err != nil { + t.Fatalf("unbound command: %v", err) + } +} + +func TestCustomBindingRouterBalancedReleaseAndMultiPress(t *testing.T) { + overrides := []BindingOverride{{DesktopKeyboardLayout, "global", PresentTime, []string{"Cthulhu+Ctrl+Alt+F8"}}} + bindings, err := ResolveCommandBindings(DesktopKeyboardLayout, true, overrides) + if err != nil { + t.Fatal(err) + } + router := NewCommandRouter([]uint32{0xff63}, bindings) + for _, want := range []Command{PresentTime, PresentDate} { + router.Handle(0xff63, 1, 0, true) + consumed, command := router.Handle(0xffc5, 2, 4|8, true) + if !consumed || command != want { + t.Fatalf("%v %s want %s", consumed, command, want) + } + router.Handle(0xff63, 1, 0, false) + if consumed, command = router.Handle(0xffc5, 2, 4|8, false); !consumed || command != "" { + t.Fatal("custom release unbalanced") + } + } +} diff --git a/internal/input/clipboard_test.go b/internal/input/clipboard_test.go new file mode 100644 index 0000000..e5e7335 --- /dev/null +++ b/internal/input/clipboard_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestClipboardChordConsumesBothReleaseOrders(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, modifierFirst := range []bool{false, true} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('c', 54, 1, true); !consumed || command != PresentClipboard { + t.Fatalf("layout %d: Cthulhu+Shift+C = %v %q", layout, consumed, command) + } + if consumed, command := router.Handle('c', 54, 1, true); !consumed || command != "" { + t.Fatalf("layout %d: repeated C = %v %q", layout, consumed, command) + } + if modifierFirst { + if consumed, command := router.Handle(0xff63, 118, 1, false); !consumed || command != "" { + t.Fatalf("layout %d: modifier release = %v %q", layout, consumed, command) + } + } + if consumed, command := router.Handle('c', 54, 1, false); !consumed || command != "" { + t.Fatalf("layout %d: C release = %v %q", layout, consumed, command) + } + if !modifierFirst { + if consumed, command := router.Handle(0xff63, 118, 0, false); !consumed || command != "" { + t.Fatalf("layout %d: modifier release = %v %q", layout, consumed, command) + } + } + } + } +} + +func TestClipboardChordRequiresShift(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('c', 54, 0, true); consumed || command != "" { + t.Fatalf("unshifted C = %v %q", consumed, command) + } +} diff --git a/internal/input/commands.go b/internal/input/commands.go new file mode 100644 index 0000000..5db6769 --- /dev/null +++ b/internal/input/commands.go @@ -0,0 +1,459 @@ +package input + +import "time" + +// Command is a semantic request; native callbacks never execute application work. +type Command string + +const ( + SelectPreviousSpeechSetting Command = "select-previous-speech-setting" + SelectNextSpeechSetting Command = "select-next-speech-setting" + DecreaseSpeechSetting Command = "decrease-speech-setting" + IncreaseSpeechSetting Command = "increase-speech-setting" + SaveSpeechSettings Command = "save-speech-settings" + PresentTime Command = "present-time" + PresentDate Command = "present-date" + PresentClipboard Command = "present-clipboard" + ShowSpeechHistory Command = "show-speech-history" + ToggleSpeechMonitor Command = "toggle-speech-monitor" + CycleSpeechMode Command = "cycle-speech-mode" + CycleProgressBarOutput Command = "cycle-progress-bar-output" + PresentVersion Command = "present-version" + ToggleLearnMode Command = "toggle-learn-mode" + ShowActions Command = "show-actions" + InternalUIKey Command = "internal-ui-key" + InternalUIDismiss Command = "internal-ui-dismiss" + LearnKey Command = "learn-key" + LearnDocumentation Command = "learn-documentation" + LearnDefaultShortcuts Command = "learn-default-shortcuts" + LearnApplicationShortcuts Command = "learn-application-shortcuts" + StickyFocusMode Command = "sticky-focus-mode" + StickyBrowseMode Command = "sticky-browse-mode" + ToggleDocumentMode Command = "toggle-document-mode" + DocumentNextHeading Command = "document-next-heading" + DocumentPreviousHeading Command = "document-previous-heading" + DocumentNextLink Command = "document-next-link" + DocumentPreviousLink Command = "document-previous-link" + DocumentNextButton Command = "document-next-button" + DocumentPreviousButton Command = "document-previous-button" + DocumentNextCheckBox Command = "document-next-check-box" + DocumentPreviousCheckBox Command = "document-previous-check-box" + DocumentNextList Command = "document-next-list" + DocumentPreviousList Command = "document-previous-list" + DocumentNextSeparator Command = "document-next-separator" + DocumentPreviousSeparator Command = "document-previous-separator" + DocumentNextHeadingAtLevel1 Command = "document-next-heading-at-level-1" + DocumentPreviousHeadingAtLevel1 Command = "document-previous-heading-at-level-1" + DocumentNextHeadingAtLevel2 Command = "document-next-heading-at-level-2" + DocumentPreviousHeadingAtLevel2 Command = "document-previous-heading-at-level-2" + DocumentNextHeadingAtLevel3 Command = "document-next-heading-at-level-3" + DocumentPreviousHeadingAtLevel3 Command = "document-previous-heading-at-level-3" + DocumentNextHeadingAtLevel4 Command = "document-next-heading-at-level-4" + DocumentPreviousHeadingAtLevel4 Command = "document-previous-heading-at-level-4" + DocumentNextHeadingAtLevel5 Command = "document-next-heading-at-level-5" + DocumentPreviousHeadingAtLevel5 Command = "document-previous-heading-at-level-5" + DocumentNextHeadingAtLevel6 Command = "document-next-heading-at-level-6" + DocumentPreviousHeadingAtLevel6 Command = "document-previous-heading-at-level-6" + DocumentNextBlockQuote Command = "document-next-block-quote" + DocumentPreviousBlockQuote Command = "document-previous-block-quote" + DocumentNextComboBox Command = "document-next-combo-box" + DocumentPreviousComboBox Command = "document-previous-combo-box" + DocumentNextEntry Command = "document-next-entry" + DocumentPreviousEntry Command = "document-previous-entry" + DocumentNextFormField Command = "document-next-form-field" + DocumentPreviousFormField Command = "document-previous-form-field" + DocumentNextImage Command = "document-next-image" + DocumentPreviousImage Command = "document-previous-image" + DocumentNextLandmark Command = "document-next-landmark" + DocumentPreviousLandmark Command = "document-previous-landmark" + DocumentNextListItem Command = "document-next-list-item" + DocumentPreviousListItem Command = "document-previous-list-item" + DocumentNextLiveRegion Command = "document-next-live-region" + DocumentPreviousLiveRegion Command = "document-previous-live-region" + DocumentNextParagraph Command = "document-next-paragraph" + DocumentPreviousParagraph Command = "document-previous-paragraph" + DocumentNextRadioButton Command = "document-next-radio-button" + DocumentPreviousRadioButton Command = "document-previous-radio-button" + DocumentNextTable Command = "document-next-table" + DocumentPreviousTable Command = "document-previous-table" + DocumentNextUnvisitedLink Command = "document-next-unvisited-link" + DocumentPreviousUnvisitedLink Command = "document-previous-unvisited-link" + DocumentNextVisitedLink Command = "document-next-visited-link" + DocumentPreviousVisitedLink Command = "document-previous-visited-link" + DocumentNextClickable Command = "document-next-clickable" + DocumentPreviousClickable Command = "document-previous-clickable" + DocumentNextChunk Command = "document-next-chunk" + DocumentPreviousChunk Command = "document-previous-chunk" + DocumentCaretNextCharacter Command = "document-caret-next-character" + DocumentCaretPreviousCharacter Command = "document-caret-previous-character" + DocumentCaretNextWord Command = "document-caret-next-word" + DocumentCaretPreviousWord Command = "document-caret-previous-word" + DocumentCaretNextLine Command = "document-caret-next-line" + DocumentCaretPreviousLine Command = "document-caret-previous-line" + DocumentCaretLineStart Command = "document-caret-line-start" + DocumentCaretLineEnd Command = "document-caret-line-end" + DocumentCaretDocumentStart Command = "document-caret-document-start" + DocumentCaretDocumentEnd Command = "document-caret-document-end" + DocumentTableLeft Command = "document-table-left" + DocumentTableRight Command = "document-table-right" + DocumentTableUp Command = "document-table-up" + DocumentTableDown Command = "document-table-down" + DocumentTableFirst Command = "document-table-first" + DocumentTableLast Command = "document-table-last" + DocumentActivate Command = "document-activate" + ReadAll Command = "read-all" + DisableTentacles Command = "disable-tentacles" + Quit Command = "quit" + Preferences Command = "preferences" + ApplicationPreferences Command = "application-preferences" + ChooseApplicationPreferences Command = "choose-application-preferences" + WhereAmI Command = "where-am-i" + PresentCharacterAttributes Command = "present-character-attributes" + PresentLastNotification Command = "present-last-notification" + PresentPreviousNotification Command = "present-previous-notification" + PresentNextNotification Command = "present-next-notification" + ShowNotificationList Command = "show-notification-list" + ObjectParent Command = "object-parent" + ObjectFirstChild Command = "object-first-child" + ObjectPreviousSibling Command = "object-previous-sibling" + ObjectNextSibling Command = "object-next-sibling" + ReviewWindowStart Command = "review-window-start" + ReviewWindowEnd Command = "review-window-end" + ReviewPreviousLine Command = "review-previous-line" + ReviewCurrentLine Command = "review-current-line" + ReviewNextLine Command = "review-next-line" + ReviewPreviousWord Command = "review-previous-word" + ReviewCurrentWord Command = "review-current-word" + ReviewSpellWord Command = "review-spell-word" + ReviewPhoneticWord Command = "review-phonetic-word" + ReviewNextWord Command = "review-next-word" + ReviewPreviousCharacter Command = "review-previous-character" + ReviewCurrentCharacter Command = "review-current-character" + ReviewPhoneticCharacter Command = "review-phonetic-character" + ReviewNextCharacter Command = "review-next-character" + ReviewWordAbove Command = "review-word-above" + ReviewWordBelow Command = "review-word-below" + ReviewLineEnd Command = "review-line-end" +) + +type KeyboardLayout int + +const ( + DesktopKeyboardLayout KeyboardLayout = 1 + LaptopKeyboardLayout KeyboardLayout = 2 +) + +const ( + returnKeySymbol uint32 = 0xff0d + keypadEnterKeySymbol uint32 = 0xff8d + leftKeySymbol uint32 = 0xff51 + upKeySymbol uint32 = 0xff52 + rightKeySymbol uint32 = 0xff53 + downKeySymbol uint32 = 0xff54 + leftControlKeySymbol uint32 = 0xffe3 + rightControlKeySymbol uint32 = 0xffe4 + leftShiftKeySymbol uint32 = 0xffe1 + rightShiftKeySymbol uint32 = 0xffe2 + controlModifier uint16 = 1 << 2 +) + +type CommandChord struct { + Symbol uint32 + Modifiers uint16 + CthulhuModifier bool +} + +type CommandBindings map[CommandChord]Command + +// CommandEvent retains the initiating X server time across asynchronous delivery. +type CommandEvent struct { + Command Command + Timestamp uint32 + Chord CommandChord + KeyName string + LearnModeStarted bool + UISession uint64 + Text string +} + +type LockState struct { + Name string + Locked bool + Timestamp uint32 +} + +func (state LockState) Text() string { + if state.Locked { + return state.Name + " on" + } + return state.Name + " off" +} + +func DefaultCommandBindings(layout KeyboardLayout) CommandBindings { + bindings := CommandBindings{ + {Symbol: 'a', Modifiers: 1, CthulhuModifier: true}: ShowActions, + {Symbol: leftKeySymbol, CthulhuModifier: true}: SelectPreviousSpeechSetting, + {Symbol: rightKeySymbol, CthulhuModifier: true}: SelectNextSpeechSetting, + {Symbol: downKeySymbol, CthulhuModifier: true}: DecreaseSpeechSetting, + {Symbol: upKeySymbol, CthulhuModifier: true}: IncreaseSpeechSetting, + {Symbol: 's', Modifiers: controlModifier | 1, CthulhuModifier: true}: SaveSpeechSettings, + {Symbol: 't', CthulhuModifier: true}: PresentTime, + {Symbol: 'c', Modifiers: 1, CthulhuModifier: true}: PresentClipboard, + {Symbol: 'h', Modifiers: controlModifier, CthulhuModifier: true}: ShowSpeechHistory, + {Symbol: 'd', Modifiers: 1, CthulhuModifier: true}: ToggleSpeechMonitor, + {Symbol: 's', CthulhuModifier: true}: CycleSpeechMode, + {Symbol: 'u', CthulhuModifier: true}: CycleProgressBarOutput, + {Symbol: 'v', Modifiers: 1, CthulhuModifier: true}: PresentVersion, + {Symbol: 'a', CthulhuModifier: true}: ToggleDocumentMode, + {Symbol: 'h', CthulhuModifier: true}: ToggleLearnMode, + {Symbol: 0xffbe, CthulhuModifier: true}: ToggleLearnMode, + {Symbol: 'h', Modifiers: 1, CthulhuModifier: true}: DocumentPreviousHeading, + {Symbol: 'k', CthulhuModifier: true, Modifiers: controlModifier}: DocumentNextLink, + {Symbol: 'k', Modifiers: controlModifier | 1, CthulhuModifier: true}: DocumentPreviousLink, + {Symbol: 'q', CthulhuModifier: true}: Quit, + {Symbol: 'q', Modifiers: 5, CthulhuModifier: true}: DisableTentacles, + {Symbol: 'n', CthulhuModifier: true}: PresentLastNotification, + {Symbol: 'n', Modifiers: controlModifier, CthulhuModifier: true}: ShowNotificationList, + {Symbol: ' ', CthulhuModifier: true}: Preferences, + {Symbol: ' ', Modifiers: controlModifier, CthulhuModifier: true}: ApplicationPreferences, + {Symbol: ' ', Modifiers: 1, CthulhuModifier: true}: ApplicationPreferences, + {Symbol: ' ', Modifiers: controlModifier | 1, CthulhuModifier: true}: ChooseApplicationPreferences, + {Symbol: 'f', CthulhuModifier: true}: PresentCharacterAttributes, + {Symbol: upKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectParent, + {Symbol: downKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectFirstChild, + {Symbol: leftKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectPreviousSibling, + {Symbol: rightKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectNextSibling, + } + if layout == DesktopKeyboardLayout { + bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] = OCRWindow + bindings[CommandChord{Symbol: 0xffab}] = ReadAll // KP_Add + bindings[CommandChord{Symbol: keypadEnterKeySymbol}] = WhereAmI + bindings[CommandChord{Symbol: 0xff95, CthulhuModifier: true}] = ReviewWindowStart + bindings[CommandChord{Symbol: 0xff9a, CthulhuModifier: true}] = ReviewWindowEnd + bindings[CommandChord{Symbol: 0xff96, CthulhuModifier: true}] = ReviewWordAbove + bindings[CommandChord{Symbol: 0xff98, CthulhuModifier: true}] = ReviewWordBelow + bindings[CommandChord{Symbol: 0xff9c, CthulhuModifier: true}] = ReviewLineEnd + } else if layout == LaptopKeyboardLayout { + bindings[CommandChord{Symbol: 'o', Modifiers: 1, CthulhuModifier: true}] = OCRWindow + bindings[CommandChord{Symbol: 'u', Modifiers: 1, CthulhuModifier: true}] = CycleProgressBarOutput + bindings[CommandChord{Symbol: ';', CthulhuModifier: true}] = ReadAll + bindings[CommandChord{Symbol: returnKeySymbol, CthulhuModifier: true}] = WhereAmI + bindings[CommandChord{Symbol: 'u', Modifiers: controlModifier, CthulhuModifier: true}] = ReviewWindowStart + bindings[CommandChord{Symbol: 'o', Modifiers: controlModifier, CthulhuModifier: true}] = ReviewWindowEnd + bindings[CommandChord{Symbol: 'j', Modifiers: controlModifier, CthulhuModifier: true}] = ReviewWordAbove + bindings[CommandChord{Symbol: 'l', Modifiers: controlModifier, CthulhuModifier: true}] = ReviewWordBelow + bindings[CommandChord{Symbol: 'm', Modifiers: controlModifier, CthulhuModifier: true}] = ReviewLineEnd + } + for _, binding := range []struct { + desktop, laptop uint32 + command Command + }{ + {0xff95, 'u', ReviewPreviousLine}, {0xff97, 'i', ReviewCurrentLine}, {0xff9a, 'o', ReviewNextLine}, + {0xff96, 'j', ReviewPreviousWord}, {0xff9d, 'k', ReviewCurrentWord}, {0xff98, 'l', ReviewNextWord}, + {0xff9c, 'm', ReviewPreviousCharacter}, {0xff99, ',', ReviewCurrentCharacter}, {0xff9b, '.', ReviewNextCharacter}, + } { + if layout == DesktopKeyboardLayout { + bindings[CommandChord{Symbol: binding.desktop}] = binding.command + } + if layout == LaptopKeyboardLayout { + bindings[CommandChord{Symbol: binding.laptop, CthulhuModifier: true}] = binding.command + } + } + return bindings +} + +// CommandRouter owns the pressed state of screen-reader modifiers and consumed +// command keys. Commands are emitted on press; native owners retain the grab +// independently until the chord releases, including during handler shutdown. +type CommandRouter struct { + now func() time.Time + lastTimePress time.Time + lastModePress time.Time + modePresses int + lastReviewPress time.Time + lastReviewChord CommandChord + reviewPresses int + modifiers map[uint32]bool + bindings CommandBindings + down map[uint16]bool + consumed map[uint16]bool +} + +func NewCommandRouter(modifiers []uint32, bindings CommandBindings) *CommandRouter { + router := &CommandRouter{ + now: time.Now, + modifiers: make(map[uint32]bool), + bindings: make(CommandBindings, len(bindings)), + down: make(map[uint16]bool), + consumed: make(map[uint16]bool), + } + for _, symbol := range modifiers { + router.modifiers[symbol] = true + } + for chord, command := range bindings { + router.bindings[chord] = command + } + return router +} + +func (router *CommandRouter) Handle(symbol uint32, code uint16, modifiers uint16, pressed bool) (bool, Command) { + consume := false + if !pressed { + consume = router.down[code] || router.consumed[code] + delete(router.down, code) + delete(router.consumed, code) + } else if router.modifiers[symbol] { + router.down[code] = true + consume = true + } else if router.consumed[code] { + consume = true + } else { + chord := CommandChord{ + Symbol: symbol, + Modifiers: modifiers &^ uint16(2|16), + CthulhuModifier: len(router.down) != 0, + } + if command := router.bindings[chord]; command != "" { + router.consumed[code] = true + command = router.review_press(command, chord) + if command == ToggleDocumentMode { + now := router.now() + if !router.lastModePress.IsZero() && now.Sub(router.lastModePress) >= 0 && now.Sub(router.lastModePress) <= 500*time.Millisecond { + router.modePresses++ + } else { + router.modePresses = 1 + } + router.lastModePress = now + switch router.modePresses { + case 2: + command = StickyFocusMode + case 3: + command = StickyBrowseMode + router.lastModePress = time.Time{} + } + } else { + router.lastModePress = time.Time{} + } + if command == PresentTime { + now := router.now() + if !router.lastTimePress.IsZero() && now.Sub(router.lastTimePress) >= 0 && now.Sub(router.lastTimePress) <= 500*time.Millisecond { + command = PresentDate + router.lastTimePress = time.Time{} + } else { + router.lastTimePress = now + } + } else { + router.lastTimePress = time.Time{} + } + return true, command + } + router.lastTimePress = time.Time{} + router.lastModePress = time.Time{} + router.lastReviewPress = time.Time{} + } + return consume, "" +} + +func (router *CommandRouter) IsModifier(symbol uint32) bool { + switch symbol { + case leftControlKeySymbol, rightControlKeySymbol, leftShiftKeySymbol, rightShiftKeySymbol, + 0xffe7, 0xffe8, 0xffe9, 0xffea, 0xffeb, 0xffec, 0xffed, 0xffee: // Meta, Alt, Super, Hyper. + return true + } + return router.modifiers[symbol] +} + +// Chord reports the keys currently held before handling the next press. +func (router *CommandRouter) Chord(symbol uint32, modifiers uint16) CommandChord { + return CommandChord{Symbol: symbol, Modifiers: modifiers &^ uint16(2|16), CthulhuModifier: len(router.down) != 0} +} + +func (router *CommandRouter) Reset() { + clear(router.down) + clear(router.consumed) +} + +// Browse bindings are installed only while the runtime owns live web content. +func BrowseCommandBindings(layout KeyboardLayout) CommandBindings { + bindings := DefaultCommandBindings(layout) + caretBindings := CommandBindings{ + {Symbol: leftKeySymbol, Modifiers: 1 | 8}: DocumentTableLeft, + {Symbol: rightKeySymbol, Modifiers: 1 | 8}: DocumentTableRight, + {Symbol: upKeySymbol, Modifiers: 1 | 8}: DocumentTableUp, + {Symbol: downKeySymbol, Modifiers: 1 | 8}: DocumentTableDown, + {Symbol: 0xff50, Modifiers: 1 | 8}: DocumentTableFirst, + {Symbol: 0xff57, Modifiers: 1 | 8}: DocumentTableLast, + {Symbol: rightKeySymbol}: DocumentCaretNextCharacter, + {Symbol: leftKeySymbol}: DocumentCaretPreviousCharacter, + {Symbol: rightKeySymbol, Modifiers: controlModifier}: DocumentCaretNextWord, + {Symbol: leftKeySymbol, Modifiers: controlModifier}: DocumentCaretPreviousWord, + {Symbol: downKeySymbol}: DocumentCaretNextLine, + {Symbol: upKeySymbol}: DocumentCaretPreviousLine, + {Symbol: 0xff50}: DocumentCaretLineStart, + {Symbol: 0xff57}: DocumentCaretLineEnd, + {Symbol: 0xff50, Modifiers: controlModifier}: DocumentCaretDocumentStart, + {Symbol: 0xff57, Modifiers: controlModifier}: DocumentCaretDocumentEnd, + {Symbol: returnKeySymbol}: DocumentActivate, + } + for chord, command := range caretBindings { + bindings[chord] = command + } + documentBindings := []struct { + symbol uint32 + next Command + previous Command + }{ + {'h', DocumentNextHeading, DocumentPreviousHeading}, + {'k', DocumentNextLink, DocumentPreviousLink}, + {'b', DocumentNextButton, DocumentPreviousButton}, + {'x', DocumentNextCheckBox, DocumentPreviousCheckBox}, + {'l', DocumentNextList, DocumentPreviousList}, + {'s', DocumentNextSeparator, DocumentPreviousSeparator}, + {'q', DocumentNextBlockQuote, DocumentPreviousBlockQuote}, + {'c', DocumentNextComboBox, DocumentPreviousComboBox}, + {'e', DocumentNextEntry, DocumentPreviousEntry}, + {'f', DocumentNextFormField, DocumentPreviousFormField}, + {'g', DocumentNextImage, DocumentPreviousImage}, + {'m', DocumentNextLandmark, DocumentPreviousLandmark}, + {'i', DocumentNextListItem, DocumentPreviousListItem}, + {'d', DocumentNextLiveRegion, DocumentPreviousLiveRegion}, + {'p', DocumentNextParagraph, DocumentPreviousParagraph}, + {'r', DocumentNextRadioButton, DocumentPreviousRadioButton}, + {'t', DocumentNextTable, DocumentPreviousTable}, + {'u', DocumentNextUnvisitedLink, DocumentPreviousUnvisitedLink}, + {'v', DocumentNextVisitedLink, DocumentPreviousVisitedLink}, + {'a', DocumentNextClickable, DocumentPreviousClickable}, + {'o', DocumentNextChunk, DocumentPreviousChunk}, + } + for _, binding := range documentBindings { + bindings[CommandChord{Symbol: binding.symbol}] = binding.next + bindings[CommandChord{Symbol: binding.symbol, Modifiers: 1}] = binding.previous + } + headingLevels := []struct { + level int + next Command + previous Command + }{ + {1, DocumentNextHeadingAtLevel1, DocumentPreviousHeadingAtLevel1}, + {2, DocumentNextHeadingAtLevel2, DocumentPreviousHeadingAtLevel2}, + {3, DocumentNextHeadingAtLevel3, DocumentPreviousHeadingAtLevel3}, + {4, DocumentNextHeadingAtLevel4, DocumentPreviousHeadingAtLevel4}, + {5, DocumentNextHeadingAtLevel5, DocumentPreviousHeadingAtLevel5}, + {6, DocumentNextHeadingAtLevel6, DocumentPreviousHeadingAtLevel6}, + } + for _, binding := range headingLevels { + symbol := uint32('0' + binding.level) + bindings[CommandChord{Symbol: symbol}] = binding.next + bindings[CommandChord{Symbol: symbol, Modifiers: 1}] = binding.previous + } + return bindings +} + +// ReplaceBindings preserves multi-press timing across browse grab changes. +// Native owners call this only after every consumed key has been released. +func (router *CommandRouter) ReplaceBindings(bindings CommandBindings) { + router.bindings = make(CommandBindings, len(bindings)) + for chord, command := range bindings { + router.bindings[chord] = command + } +} diff --git a/internal/input/commands_test.go b/internal/input/commands_test.go new file mode 100644 index 0000000..1724302 --- /dev/null +++ b/internal/input/commands_test.go @@ -0,0 +1,298 @@ +package input + +import "testing" + +func TestQuitConsumesBalancedChord(t *testing.T) { + for _, modifierFirst := range []bool{false, true} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + check := func(symbol uint32, code uint16, pressed, consume bool, command Command) { + t.Helper() + gotConsume, gotCommand := router.Handle(symbol, code, 0, pressed) + if gotConsume != consume || gotCommand != command { + t.Fatalf("got %v %q, want %v %q", gotConsume, gotCommand, consume, command) + } + } + check('q', 24, true, false, "") + check('q', 24, false, false, "") + check(0xff63, 118, true, true, "") + check('q', 24, true, true, Quit) + check('q', 24, true, true, "") // auto-repeat + if modifierFirst { + check(0xff63, 118, false, true, "") + check('q', 24, false, true, "") + } else { + check('q', 24, false, true, "") + check(0xff63, 118, false, true, "") + } + check('q', 24, true, false, "") + } +} + +func TestUnknownChordPassesThrough(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('x', 53, 0, true); consumed || command != "" { + t.Fatal("unknown command consumed") + } + router.Reset() + if consumed, command := router.Handle('x', 53, 0, false); consumed || command != "" { + t.Fatal("unknown command release consumed after pass-through reset") + } + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('q', 24, 4, true); consumed || command != "" { + t.Fatal("Control+Insert+q became quit") + } + router.Reset() + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('q', 24, 2|16, true); !consumed || command != Quit { + t.Fatal("lock keys prevented quit chord") + } +} + +func TestBindingTableRoutesPreferencesOnPress(t *testing.T) { + bindings := DefaultCommandBindings(DesktopKeyboardLayout) + router := NewCommandRouter([]uint32{0xff63, 0xff9e}, bindings) + bindings[CommandChord{Symbol: ' ', CthulhuModifier: true}] = Quit + + if consumed, command := router.Handle(0xff9e, 90, 0, true); !consumed || command != "" { + t.Fatalf("modifier press = %v %q", consumed, command) + } + if consumed, command := router.Handle(' ', 65, 0, true); !consumed || command != Preferences { + t.Fatalf("space press = %v %q", consumed, command) + } + if consumed, command := router.Handle(0xff9e, 90, 0, false); !consumed || command != "" { + t.Fatalf("modifier release = %v %q", consumed, command) + } + if consumed, command := router.Handle(' ', 65, 0, false); !consumed || command != "" { + t.Fatalf("space release = %v %q, want no command", consumed, command) + } +} + +func TestApplicationPreferencesShortcut(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + if consumed, command := router.Handle(0xff63, 90, 0, true); !consumed || command != "" { + t.Fatalf("modifier press = %v %q", consumed, command) + } + if consumed, command := router.Handle(' ', 65, controlModifier, true); !consumed || command != ApplicationPreferences { + t.Fatalf("Cthulhu+Ctrl+Space = %v %q", consumed, command) + } + if consumed, command := router.Handle(' ', 65, controlModifier, false); !consumed || command != "" { + t.Fatalf("shortcut release = %v %q", consumed, command) + } +} + +func TestApplicationPreferenceShiftShortcutsInBothLayouts(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, tc := range []struct { + modifiers uint16 + want Command + }{ + {1, ApplicationPreferences}, {controlModifier | 1, ChooseApplicationPreferences}, + } { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + router.Handle(0xff63, 90, 0, true) + if consumed, command := router.Handle(' ', 65, tc.modifiers, true); !consumed || command != tc.want { + t.Fatalf("layout %v shortcut = %v %q, want %q", layout, consumed, command, tc.want) + } + if consumed, command := router.Handle(' ', 65, tc.modifiers, false); !consumed || command != "" { + t.Fatalf("shortcut release = %v %q", consumed, command) + } + } + } +} + +func TestLaptopBindingRoutesWhereAmI(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(LaptopKeyboardLayout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(returnKeySymbol, 36, 0, true); !consumed || command != WhereAmI { + t.Fatalf("Return press = %v %q", consumed, command) + } + if consumed, command := router.Handle(returnKeySymbol, 36, 0, false); !consumed || command != "" { + t.Fatalf("Return release = %v %q", consumed, command) + } + if consumed, command := router.Handle(0xff63, 118, 0, false); !consumed || command != "" { + t.Fatalf("modifier release = %v %q, want no command", consumed, command) + } +} + +func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + bindings := DefaultCommandBindings(layout) + if got := bindings[CommandChord{Symbol: 'f', CthulhuModifier: true}]; got != PresentCharacterAttributes { + t.Fatalf("layout %v Cthulhu+F = %q", layout, got) + } + } +} + +func TestBrowseReturnActivatesWithoutChangingDefaultReturn(t *testing.T) { + for name, bindings := range map[string]CommandBindings{ + "default": DefaultCommandBindings(DesktopKeyboardLayout), + "browse": BrowseCommandBindings(DesktopKeyboardLayout), + } { + router := NewCommandRouter([]uint32{0xff63}, bindings) + consumed, command := router.Handle(returnKeySymbol, 36, 0, true) + if name == "browse" && (!consumed || command != DocumentActivate) { + t.Fatalf("browse Return = %v %q", consumed, command) + } + if name == "default" && (consumed || command != "") { + t.Fatalf("default Return = %v %q", consumed, command) + } + } +} + +func TestDesktopBindingRoutesWhereAmIWithoutModifier(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + if consumed, command := router.Handle(keypadEnterKeySymbol, 104, 0, true); !consumed || command != WhereAmI { + t.Fatalf("KP Enter press = %v %q", consumed, command) + } + if consumed, command := router.Handle(keypadEnterKeySymbol, 104, 0, false); !consumed || command != "" { + t.Fatalf("KP Enter release = %v %q, want no command", consumed, command) + } +} + +func TestWhereAmIBindingsAreLayoutSpecific(t *testing.T) { + desktop := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + desktop.Handle(0xff63, 118, 0, true) + if consumed, command := desktop.Handle(returnKeySymbol, 36, 0, true); consumed || command != "" { + t.Fatalf("desktop Cthulhu+Return = %v %q, want unbound", consumed, command) + } + + laptop := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(LaptopKeyboardLayout)) + if consumed, command := laptop.Handle(keypadEnterKeySymbol, 104, 0, true); consumed || command != "" { + t.Fatalf("laptop KP Enter = %v %q, want unbound", consumed, command) + } +} + +func TestObjectNavigationBindingsMatchPython(t *testing.T) { + tests := []struct { + symbol uint32 + command Command + }{ + {upKeySymbol, ObjectParent}, + {downKeySymbol, ObjectFirstChild}, + {leftKeySymbol, ObjectPreviousSibling}, + {rightKeySymbol, ObjectNextSibling}, + } + for _, test := range tests { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(test.symbol, 111, controlModifier, true); !consumed || command != test.command { + t.Fatalf("symbol %#x press = %v %q", test.symbol, consumed, command) + } + if consumed, command := router.Handle(test.symbol, 111, controlModifier, false); !consumed || command != "" { + t.Fatalf("symbol %#x release = %v %q", test.symbol, consumed, command) + } + if consumed, command := router.Handle(0xff63, 118, controlModifier, false); !consumed || command != "" { + t.Fatalf("symbol %#x modifier release = %v %q, want %q", test.symbol, consumed, command, Command("")) + } + } +} + +func TestObjectNavigationRequiresControl(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(upKeySymbol, 111, 0, true); !consumed || command != IncreaseSpeechSetting { + t.Fatalf("Cthulhu+Up = %v %q, want speech adjustment", consumed, command) + } +} + +func TestControlPressDoesNotMakeObjectNavigationPassThrough(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(DesktopKeyboardLayout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(leftControlKeySymbol, 37, 0, true); consumed || command != "" { + t.Fatalf("Control press = %v %q", consumed, command) + } + if !router.IsModifier(leftControlKeySymbol) || !router.IsModifier(rightControlKeySymbol) { + t.Fatal("Control key was not classified as a chord modifier") + } + if consumed, command := router.Handle(upKeySymbol, 111, controlModifier, true); !consumed || command != ObjectParent { + t.Fatalf("Up press = %v %q", consumed, command) + } +} + +func TestBindingTableMatchesExactAdditionalModifiers(t *testing.T) { + bindings := CommandBindings{{Symbol: 'x', Modifiers: 4, CthulhuModifier: true}: Preferences} + router := NewCommandRouter([]uint32{0xff63}, bindings) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('x', 53, 0, true); consumed || command != "" { + t.Fatal("binding matched without its Control modifier") + } + router.Reset() + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('x', 53, 4, true); !consumed || command != Preferences { + t.Fatal("binding did not match its Control modifier") + } +} + +func TestBrowseCommandsPreserveShiftAndReleaseOrder(t *testing.T) { + for _, tc := range []struct { + symbol uint32 + mods uint16 + want Command + }{ + {'h', 0, DocumentNextHeading}, {'h', 1, DocumentPreviousHeading}, + {'k', 0, DocumentNextLink}, {'k', 1, DocumentPreviousLink}, + {'b', 0, DocumentNextButton}, {'b', 1, DocumentPreviousButton}, + {'x', 0, DocumentNextCheckBox}, {'x', 1, DocumentPreviousCheckBox}, + {'l', 0, DocumentNextList}, {'l', 1, DocumentPreviousList}, + {'s', 0, DocumentNextSeparator}, {'s', 1, DocumentPreviousSeparator}, + {'q', 0, DocumentNextBlockQuote}, {'q', 1, DocumentPreviousBlockQuote}, + {'c', 0, DocumentNextComboBox}, {'c', 1, DocumentPreviousComboBox}, + {'e', 0, DocumentNextEntry}, {'e', 1, DocumentPreviousEntry}, + {'f', 0, DocumentNextFormField}, {'f', 1, DocumentPreviousFormField}, + {'g', 0, DocumentNextImage}, {'g', 1, DocumentPreviousImage}, + {'m', 0, DocumentNextLandmark}, {'m', 1, DocumentPreviousLandmark}, + {'i', 0, DocumentNextListItem}, {'i', 1, DocumentPreviousListItem}, + {'d', 0, DocumentNextLiveRegion}, {'d', 1, DocumentPreviousLiveRegion}, + {'p', 0, DocumentNextParagraph}, {'p', 1, DocumentPreviousParagraph}, + {'r', 0, DocumentNextRadioButton}, {'r', 1, DocumentPreviousRadioButton}, + {'t', 0, DocumentNextTable}, {'t', 1, DocumentPreviousTable}, + {'u', 0, DocumentNextUnvisitedLink}, {'u', 1, DocumentPreviousUnvisitedLink}, + {'v', 0, DocumentNextVisitedLink}, {'v', 1, DocumentPreviousVisitedLink}, + {'a', 0, DocumentNextClickable}, {'a', 1, DocumentPreviousClickable}, + {'o', 0, DocumentNextChunk}, {'o', 1, DocumentPreviousChunk}, + {'1', 0, DocumentNextHeadingAtLevel1}, {'1', 1, DocumentPreviousHeadingAtLevel1}, + {'2', 0, DocumentNextHeadingAtLevel2}, {'2', 1, DocumentPreviousHeadingAtLevel2}, + {'3', 0, DocumentNextHeadingAtLevel3}, {'3', 1, DocumentPreviousHeadingAtLevel3}, + {'4', 0, DocumentNextHeadingAtLevel4}, {'4', 1, DocumentPreviousHeadingAtLevel4}, + {'5', 0, DocumentNextHeadingAtLevel5}, {'5', 1, DocumentPreviousHeadingAtLevel5}, + {'6', 0, DocumentNextHeadingAtLevel6}, {'6', 1, DocumentPreviousHeadingAtLevel6}, + {'h', 4, ""}, {'k', 8, ""}, + } { + router := NewCommandRouter([]uint32{0xff63}, BrowseCommandBindings(DesktopKeyboardLayout)) + consumed, command := router.Handle(tc.symbol, 43, tc.mods, true) + if consumed != (tc.want != "") || command != tc.want { + t.Fatalf("press: %v %s", consumed, command) + } + consumed, command = router.Handle(tc.symbol, 43, tc.mods, false) + if consumed != (tc.want != "") || command != "" { + t.Fatalf("release: %v %s want %s", consumed, command, Command("")) + } + } +} + +func TestSpeechSettingBindings(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, bindings := range []CommandBindings{DefaultCommandBindings(layout), BrowseCommandBindings(layout)} { + for _, test := range []struct { + symbol uint32 + modifiers uint16 + command Command + }{ + {leftKeySymbol, 0, SelectPreviousSpeechSetting}, {rightKeySymbol, 0, SelectNextSpeechSetting}, + {upKeySymbol, 0, IncreaseSpeechSetting}, {downKeySymbol, 0, DecreaseSpeechSetting}, + {'s', controlModifier | 1, SaveSpeechSettings}, + } { + router := NewCommandRouter([]uint32{0xff63}, bindings) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(test.symbol, 111, test.modifiers, true); !consumed || command != test.command { + t.Fatalf("press: %v %q", consumed, command) + } + router.Handle(0xff63, 118, 0, false) + if consumed, command := router.Handle(test.symbol, 111, test.modifiers, false); !consumed || command != "" { + t.Fatalf("release: %v %q", consumed, command) + } + } + } + } +} diff --git a/internal/input/date_time_test.go b/internal/input/date_time_test.go new file mode 100644 index 0000000..0e23919 --- /dev/null +++ b/internal/input/date_time_test.go @@ -0,0 +1,47 @@ +package input + +import ( + "testing" + "time" +) + +func TestTimeDatePresses(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, modifierFirst := range []bool{false, true} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + now := time.Unix(100, 0) + router.now = func() time.Time { return now } + press := func(want Command) { + t.Helper() + router.Handle(0xff63, 118, 0, true) + if consumed, got := router.Handle('t', 28, 0, true); !consumed || got != want { + t.Fatalf("press = %v %s; want %s", consumed, got, want) + } + if consumed, got := router.Handle('t', 28, 0, true); !consumed || got != "" { + t.Fatal("held key repeated command") + } + codes := []uint16{28, 118} + if modifierFirst { + codes = []uint16{118, 28} + } + for _, code := range codes { + if consumed, got := router.Handle(0, code, 0, false); !consumed || got != "" { + t.Fatal("unbalanced release") + } + } + router.Reset() // Native owner finishes each fully released chord. + } + press(PresentTime) + now = now.Add(500 * time.Millisecond) + press(PresentDate) + press(PresentTime) + now = now.Add(501 * time.Millisecond) + press(PresentTime) + router.Handle(0xff63, 118, 0, true) + router.Handle('x', 53, 0, true) + router.Handle('x', 53, 0, false) + router.Handle(0xff63, 118, 0, false) + press(PresentTime) + } + } +} diff --git a/internal/input/document_test.go b/internal/input/document_test.go new file mode 100644 index 0000000..44c25f5 --- /dev/null +++ b/internal/input/document_test.go @@ -0,0 +1,50 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package input + +import "testing" + +func TestDocumentChords(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, tc := range []struct { + symbol uint32 + mask uint16 + want Command + }{ + {'h', 0, ToggleLearnMode}, {'h', 1, DocumentPreviousHeading}, + {'k', controlModifier, DocumentNextLink}, {'k', controlModifier | 1, DocumentPreviousLink}, + } { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + if consumed, _ := router.Handle(tc.symbol, 43, tc.mask, true); consumed { + t.Fatal("plain document key consumed") + } + router.Handle(tc.symbol, 43, tc.mask, false) + router.Handle(0xff63, 118, tc.mask, true) + if consumed, command := router.Handle(tc.symbol, 43, tc.mask, true); !consumed || command != tc.want { + t.Fatal("press did not dispatch command") + } + router.Handle(0xff63, 118, tc.mask, false) + if consumed, command := router.Handle(tc.symbol, 43, tc.mask, false); !consumed || command != "" { + t.Fatalf("got %v %v want %v", consumed, command, tc.want) + } + } + } + if got := DefaultCommandBindings(LaptopKeyboardLayout)[CommandChord{Symbol: 'k', CthulhuModifier: true}]; got != ReviewCurrentWord { + t.Fatal("laptop word binding replaced") + } +} diff --git a/internal/input/flat_review_test.go b/internal/input/flat_review_test.go new file mode 100644 index 0000000..e50f6a1 --- /dev/null +++ b/internal/input/flat_review_test.go @@ -0,0 +1,185 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package input + +import ( + "testing" + "time" +) + +func TestReviewMultiPressSequences(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, modifierFirst := range []bool{false, true} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + now := time.Unix(100, 0) + router.now = func() time.Time { return now } + wordKey, charKey := uint32(0xff9d), uint32(0xff99) + if layout == LaptopKeyboardLayout { + wordKey, charKey = 'k', ',' + } + press := func(symbol uint32, want Command) { + t.Helper() + if layout == LaptopKeyboardLayout { + router.Handle(0xff63, 118, 0, true) + } + if consumed, got := router.Handle(symbol, 30, 0, true); !consumed || got != want { + t.Fatalf("layout=%d press=%x got=%s want=%s", layout, symbol, got, want) + } + if consumed, got := router.Handle(symbol, 30, 0, true); !consumed || got != "" { + t.Fatal("held-key repeat dispatched") + } + if layout == LaptopKeyboardLayout && modifierFirst { + router.Handle(0xff63, 118, 0, false) + } + if consumed, got := router.Handle(symbol, 30, 0, false); !consumed || got != "" { + t.Fatal("unbalanced release") + } + if layout == LaptopKeyboardLayout && !modifierFirst { + router.Handle(0xff63, 118, 0, false) + } + router.Reset() + router.ReplaceBindings(BrowseCommandBindings(layout)) + } + press(charKey, ReviewCurrentCharacter) + now = now.Add(500 * time.Millisecond) + press(charKey, ReviewPhoneticCharacter) + press(charKey, ReviewCurrentCharacter) + press(wordKey, ReviewCurrentWord) // Different chord resets the sequence. + press(wordKey, ReviewSpellWord) + press(wordKey, ReviewPhoneticWord) + press(wordKey, ReviewCurrentWord) + now = now.Add(501 * time.Millisecond) + press(wordKey, ReviewCurrentWord) + router.Handle(0xffbe, 67, 0, true) // Unbound intervening key. + router.Handle(0xffbe, 67, 0, false) + press(wordKey, ReviewCurrentWord) + now = now.Add(-time.Millisecond) + press(wordKey, ReviewCurrentWord) // Clock reversal must not count. + } + } +} + +func TestFlatReviewLayoutBindingsAndBalancedRelease(t *testing.T) { + for _, tc := range []struct { + desktop, laptop uint32 + command Command + }{ + {0xff95, 'u', ReviewPreviousLine}, {0xff97, 'i', ReviewCurrentLine}, {0xff9a, 'o', ReviewNextLine}, + {0xff96, 'j', ReviewPreviousWord}, {0xff9d, 'k', ReviewCurrentWord}, {0xff98, 'l', ReviewNextWord}, + {0xff9c, 'm', ReviewPreviousCharacter}, {0xff99, ',', ReviewCurrentCharacter}, {0xff9b, '.', ReviewNextCharacter}, + } { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, modifierFirst := range []bool{false, true} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + symbol := tc.desktop + if layout == LaptopKeyboardLayout { + symbol = tc.laptop + if consumed, _ := router.Handle(symbol, 30, 0, true); consumed { + t.Fatal("unmodified laptop letter consumed") + } + router.Handle(symbol, 30, 0, false) + router.Handle(0xff63, 118, 0, true) + } + if consumed, cmd := router.Handle(symbol, 30, 0, true); !consumed || cmd != tc.command { + t.Fatalf("press: %v %q", consumed, cmd) + } + if consumed, cmd := router.Handle(symbol, 30, 0, true); !consumed || cmd != "" { + t.Fatalf("repeat: %v %q", consumed, cmd) + } + if layout == LaptopKeyboardLayout && modifierFirst { + router.Handle(0xff63, 118, 0, false) + } + consumed, cmd := router.Handle(symbol, 30, 0, false) + if !consumed { + t.Fatal("review release leaked") + } + if layout == LaptopKeyboardLayout && !modifierFirst { + if cmd != "" { + t.Fatal("command emitted while Insert held") + } + _, cmd = router.Handle(0xff63, 118, 0, false) + } + if cmd != "" { + t.Fatalf("release dispatched %q", cmd) + } + } + } + } +} + +func TestFlatReviewWindowBindingsAndBalancedRelease(t *testing.T) { + for _, tc := range []struct { + symbol uint32 + layout KeyboardLayout + modifiers uint16 + command Command + }{ + {0xff95, DesktopKeyboardLayout, 0, ReviewWindowStart}, + {0xff9a, DesktopKeyboardLayout, 0, ReviewWindowEnd}, + {'u', LaptopKeyboardLayout, controlModifier, ReviewWindowStart}, + {'o', LaptopKeyboardLayout, controlModifier, ReviewWindowEnd}, + } { + for _, releaseModifierFirst := range []bool{false, true} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(tc.layout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(tc.symbol, 30, tc.modifiers, true); !consumed || command != tc.command { + t.Fatalf("press: consumed=%v command=%q", consumed, command) + } + releases := []struct { + symbol uint32 + code uint16 + }{{tc.symbol, 30}, {0xff63, 118}} + if releaseModifierFirst { + releases[0], releases[1] = releases[1], releases[0] + } + for _, key := range releases { + if consumed, command := router.Handle(key.symbol, key.code, 0, false); !consumed || command != "" { + t.Fatalf("release: consumed=%v command=%q", consumed, command) + } + } + } + } +} + +func TestFlatReviewVerticalAndLineEndBindings(t *testing.T) { + for _, tc := range []struct { + symbol uint32 + layout KeyboardLayout + modifiers uint16 + command Command + }{ + {0xff96, DesktopKeyboardLayout, 0, ReviewWordAbove}, + {0xff98, DesktopKeyboardLayout, 0, ReviewWordBelow}, + {0xff9c, DesktopKeyboardLayout, 0, ReviewLineEnd}, + {'j', LaptopKeyboardLayout, controlModifier, ReviewWordAbove}, + {'l', LaptopKeyboardLayout, controlModifier, ReviewWordBelow}, + {'m', LaptopKeyboardLayout, controlModifier, ReviewLineEnd}, + } { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(tc.layout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle(tc.symbol, 30, tc.modifiers, true); !consumed || command != tc.command { + t.Fatalf("press: consumed=%v command=%q want=%q", consumed, command, tc.command) + } + if consumed, command := router.Handle(tc.symbol, 30, 0, false); !consumed || command != "" { + t.Fatalf("release: consumed=%v command=%q", consumed, command) + } + if consumed, command := router.Handle(0xff63, 118, 0, false); !consumed || command != "" { + t.Fatalf("modifier release: consumed=%v command=%q", consumed, command) + } + } +} diff --git a/internal/input/input.go b/internal/input/input.go new file mode 100644 index 0000000..609cae8 --- /dev/null +++ b/internal/input/input.go @@ -0,0 +1,125 @@ +package input + +import ( + "strconv" + "strings" +) + +type KeyKind uint8 + +const ( + KeyUnknown KeyKind = iota + KeyModifier + KeyFunction + KeyAction + KeyNavigation + KeyDiacritical +) + +type Event struct { + Timestamp uint32 // X server milliseconds, for ordering independent listeners. + Keycode uint8 + Pressed bool + Repeat bool // X11 autorepeat, distinct from a new physical key press. + Name string + Text string + Kind KeyKind +} + +type EchoSettings struct { + EnableKeyEcho bool + EnableModifierKeys bool + EnableFunctionKeys bool + EnableActionKeys bool + EnableNavigationKeys bool + EnableDiacriticalKeys bool + CthulhuModifierKeys []string +} + +func NewKeyEvent(keycode uint8, pressed bool, name string) Event { + kind, text := classifyKey(name) + return Event{Keycode: keycode, Pressed: pressed, Name: name, Text: text, Kind: kind} +} + +func EchoText(event Event, settings EchoSettings) string { + if !event.Pressed || !settings.EnableKeyEcho { + return "" + } + var enabled bool + switch event.Kind { + case KeyModifier: + enabled = settings.EnableModifierKeys + case KeyFunction: + enabled = settings.EnableFunctionKeys + case KeyAction: + enabled = settings.EnableActionKeys + case KeyNavigation: + enabled = settings.EnableNavigationKeys + case KeyDiacritical: + enabled = settings.EnableDiacriticalKeys + } + if !enabled { + return "" + } + return event.Text +} + +func classifyKey(name string) (KeyKind, string) { + if text, exists := map[string]string{ + "Shift_L": "left shift", "Shift_R": "right shift", + "Control_L": "left control", "Control_R": "right control", + "Alt_L": "left alt", "Alt_R": "right alt", + "Meta_L": "left meta", "Meta_R": "right meta", + "ISO_Level3_Shift": "Alt Gr", + }[name]; exists { + return KeyModifier, text + } + if strings.HasPrefix(name, "F") { + number, err := strconv.Atoi(strings.TrimPrefix(name, "F")) + if err == nil && number >= 1 && number <= 12 { + return KeyFunction, strings.ToLower(name) + } + } + if text, exists := map[string]string{ + "Return": "return", "KP_Enter": "enter", "Escape": "escape", + "Tab": "tab", "ISO_Left_Tab": "left tab", "BackSpace": "backspace", + "Delete": "delete", "KP_Delete": "delete", + "Page_Up": "page up", "KP_Page_Up": "page up", "Prior": "page up", "KP_Prior": "page up", + "Page_Down": "page down", "KP_Page_Down": "page down", "Next": "page down", "KP_Next": "page down", + }[name]; exists { + return KeyAction, text + } + if text, exists := map[string]string{ + "Left": "left", "KP_Left": "left", "Right": "right", "KP_Right": "right", + "Up": "up", "KP_Up": "up", "Down": "down", "KP_Down": "down", + "Home": "home", "KP_Home": "home", "End": "end", "KP_End": "end", + }[name]; exists { + return KeyNavigation, text + } + if strings.HasPrefix(name, "dead_") { + text := strings.ReplaceAll(strings.TrimPrefix(name, "dead_"), "_", " ") + if text == "abovering" { + text = "ring" + } + return KeyDiacritical, text + } + return KeyUnknown, "" +} + +type ModifierKeycodes [256]bool + +func NewModifierKeycodes(keycodes ...uint8) ModifierKeycodes { + var modifiers ModifierKeycodes + for _, keycode := range keycodes { + modifiers[keycode] = true + } + return modifiers +} + +func (modifiers ModifierKeycodes) Contains(keycode uint8) bool { + return modifiers[keycode] +} + +func ShouldInterruptSpeech(event Event) bool { + return event.Pressed +} diff --git a/internal/input/input_test.go b/internal/input/input_test.go new file mode 100644 index 0000000..326ea05 --- /dev/null +++ b/internal/input/input_test.go @@ -0,0 +1,89 @@ +package input + +import "testing" + +func TestShouldInterruptSpeechOnKeyPress(t *testing.T) { + tests := []struct { + name string + event Event + want bool + }{ + {name: "left control press", event: Event{Keycode: 37, Pressed: true}, want: true}, + {name: "left shift press", event: Event{Keycode: 50, Pressed: true}, want: true}, + {name: "right shift press", event: Event{Keycode: 62, Pressed: true}, want: true}, + {name: "left control release", event: Event{Keycode: 37, Pressed: false}, want: false}, + {name: "letter press", event: Event{Keycode: 38, Pressed: true}, want: true}, + {name: "up press at table boundary", event: NewKeyEvent(111, true, "Up"), want: true}, + {name: "up release", event: NewKeyEvent(111, false, "Up"), want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := ShouldInterruptSpeech(test.event); got != test.want { + t.Fatalf("ShouldInterruptSpeech() = %v, want %v", got, test.want) + } + }) + } +} + +func TestModifierKeycodesSupportsHighX11Keycodes(t *testing.T) { + modifiers := NewModifierKeycodes(37, 105) + + if !modifiers.Contains(37) || !modifiers.Contains(105) { + t.Fatal("Control keycodes were omitted") + } + if modifiers.Contains(38) { + t.Fatal("unrelated keycode was marked as a modifier") + } +} + +func TestNewKeyEventClassifiesNonPrintableKeys(t *testing.T) { + tests := []struct { + name string + kind KeyKind + text string + }{ + {name: "Control_L", kind: KeyModifier, text: "left control"}, + {name: "ISO_Level3_Shift", kind: KeyModifier, text: "Alt Gr"}, + {name: "F12", kind: KeyFunction, text: "f12"}, + {name: "Return", kind: KeyAction, text: "return"}, + {name: "KP_Page_Down", kind: KeyAction, text: "page down"}, + {name: "Left", kind: KeyNavigation, text: "left"}, + {name: "KP_Home", kind: KeyNavigation, text: "home"}, + {name: "dead_acute", kind: KeyDiacritical, text: "acute"}, + {name: "dead_abovering", kind: KeyDiacritical, text: "ring"}, + {name: "a", kind: KeyUnknown, text: ""}, + {name: "F13", kind: KeyUnknown, text: ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + event := NewKeyEvent(42, true, test.name) + if event.Kind != test.kind || event.Text != test.text || event.Name != test.name { + t.Fatalf("event = %+v, want kind %v and text %q", event, test.kind, test.text) + } + }) + } +} + +func TestEchoTextHonorsNonPrintableCategories(t *testing.T) { + settings := EchoSettings{EnableKeyEcho: true, EnableModifierKeys: true, EnableFunctionKeys: true, + EnableActionKeys: true, EnableNavigationKeys: true, EnableDiacriticalKeys: true} + for _, name := range []string{"Control_L", "F1", "Tab", "Right", "dead_acute"} { + event := NewKeyEvent(42, true, name) + if got := EchoText(event, settings); got == "" { + t.Fatalf("EchoText(%q) was empty", name) + } + } + settings.EnableNavigationKeys = false + if got := EchoText(NewKeyEvent(42, true, "Right"), settings); got != "" { + t.Fatalf("disabled navigation echo = %q", got) + } + settings.EnableKeyEcho = false + if got := EchoText(NewKeyEvent(42, true, "F1"), settings); got != "" { + t.Fatalf("disabled key echo = %q", got) + } + settings.EnableKeyEcho = true + if got := EchoText(NewKeyEvent(42, false, "F1"), settings); got != "" { + t.Fatalf("release echo = %q", got) + } +} diff --git a/internal/input/intent.go b/internal/input/intent.go new file mode 100644 index 0000000..8b57101 --- /dev/null +++ b/internal/input/intent.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import ( + "strings" + "sync" + "time" +) + +// Intent records only the kind of input, never the text the user typed. +type Intent uint8 + +const ( + NoIntent Intent = iota + TypingIntent + LineIntent + WordIntent + CharacterIntent + DeleteIntent + OtherIntent +) + +type Activity struct { + Selecting bool + Intent Intent + Sequence uint64 + Clipboard ClipboardAction +} + +type ClipboardAction uint8 + +const ( + NoClipboardAction ClipboardAction = iota + CopyAction + CutAction + PasteAction +) + +type IntentTracker struct { + sequence uint64 + mutex sync.Mutex + held map[string]bool + intent Intent + clipboard ClipboardAction + selecting bool + at time.Time +} + +func (tracker *IntentTracker) Observe(event Event) { + tracker.mutex.Lock() + defer tracker.mutex.Unlock() + if event.Pressed { + tracker.sequence++ + } + if tracker.held == nil { + tracker.held = make(map[string]bool) + } + if event.Kind == KeyModifier { + tracker.held[event.Name] = event.Pressed + if event.Pressed { + tracker.intent, tracker.at = OtherIntent, time.Now() + tracker.clipboard = NoClipboardAction + tracker.selecting = false + } + return + } + if !event.Pressed { + return + } + ctrl := tracker.held["Control_L"] || tracker.held["Control_R"] + shift := tracker.held["Shift_L"] || tracker.held["Shift_R"] + alt := tracker.held["Alt_L"] || tracker.held["Alt_R"] || tracker.held["Meta_L"] || tracker.held["Meta_R"] + intent := OtherIntent + tracker.clipboard = NoClipboardAction + name := strings.TrimPrefix(event.Name, "KP_") + if !alt { + if ctrl && !shift { + switch strings.ToLower(name) { + case "c": + tracker.clipboard = CopyAction + case "x": + tracker.clipboard = CutAction + case "v": + tracker.clipboard = PasteAction + } + } + switch name { + case "Up", "Down", "Page_Up", "Page_Down", "Prior", "Next": + intent = LineIntent + case "Left", "Right": + intent = CharacterIntent + if ctrl { + intent = WordIntent + } + case "Home", "End": + intent = CharacterIntent + if ctrl { + intent = LineIntent + } + case "Insert", "Caps_Lock", "Num_Lock", "Scroll_Lock", "Super_L", "Super_R": + intent = OtherIntent + case "BackSpace", "Delete": + intent = DeleteIntent + default: + if !ctrl && (event.Name != "" && event.Kind == KeyUnknown) { + intent = TypingIntent + } + } + } + tracker.selecting = shift && (intent == CharacterIntent || intent == WordIntent || intent == LineIntent) + tracker.intent, tracker.at = intent, time.Now() +} + +func (tracker *IntentTracker) RecentIntent() Intent { return tracker.RecentInput().Intent } + +// Reset retires held modifiers and presentation intent across secure input. +func (tracker *IntentTracker) Reset() { + tracker.mutex.Lock() + defer tracker.mutex.Unlock() + clear(tracker.held) + tracker.intent, tracker.at = OtherIntent, time.Time{} + tracker.clipboard, tracker.selecting = NoClipboardAction, false +} + +func (tracker *IntentTracker) RecentInput() Activity { + tracker.mutex.Lock() + defer tracker.mutex.Unlock() + if time.Since(tracker.at) > time.Second { + return Activity{} + } + return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting} +} diff --git a/internal/input/intent_test.go b/internal/input/intent_test.go new file mode 100644 index 0000000..8a5b3fa --- /dev/null +++ b/internal/input/intent_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import ( + "testing" + "time" +) + +func TestIntentTracksModifiersAndExpires(t *testing.T) { + tracker := &IntentTracker{} + for _, step := range []struct { + name string + pressed bool + want Intent + }{ + {"Down", true, LineIntent}, {"Down", false, LineIntent}, + {"Right", true, CharacterIntent}, {"Control_L", true, OtherIntent}, + {"Right", true, WordIntent}, {"Control_R", true, OtherIntent}, + {"Control_L", false, OtherIntent}, {"Left", true, WordIntent}, + {"Control_R", false, WordIntent}, {"Left", true, CharacterIntent}, + {"s", true, TypingIntent}, {"period", true, TypingIntent}, + {"BackSpace", true, DeleteIntent}, {"Return", true, OtherIntent}, + {"Alt_L", true, OtherIntent}, {"Down", true, OtherIntent}, + } { + tracker.Observe(NewKeyEvent(0, step.pressed, step.name)) + if got := tracker.RecentIntent(); got != step.want { + t.Fatalf("%s pressed=%v: %v, want %v", step.name, step.pressed, got, step.want) + } + } + tracker.at = time.Now().Add(-2 * time.Second) + if tracker.RecentIntent() != NoIntent { + t.Fatal("stale input reused") + } +} + +func TestIntentTracksClipboardChords(t *testing.T) { + tracker := &IntentTracker{} + press := func(name string) Activity { + tracker.Observe(NewKeyEvent(0, true, name)) + return tracker.RecentInput() + } + press("Shift_L") + if got := press("Right"); got.Intent != CharacterIntent || !got.Selecting { + t.Fatalf("Shift+Right = %+v", got) + } + press("Control_L") + if got := press("Left"); got.Intent != WordIntent || !got.Selecting { + t.Fatalf("Ctrl+Shift+Left = %+v", got) + } + tracker.Observe(NewKeyEvent(0, false, "Shift_L")) + for _, tc := range []struct { + key string + want ClipboardAction + }{ + {"c", CopyAction}, {"x", CutAction}, {"v", PasteAction}, + } { + if got := press(tc.key); got.Clipboard != tc.want { + t.Fatalf("Ctrl+%s = %+v", tc.key, got) + } + } + if got := press("Right"); got.Clipboard != NoClipboardAction { + t.Fatalf("next key retained clipboard action: %+v", got) + } + tracker.Observe(NewKeyEvent(0, false, "Control_L")) + if got := press("v"); got.Clipboard != NoClipboardAction { + t.Fatalf("plain v = %+v", got) + } +} + +func TestSelectionIntentEndsWithPlainNavigation(t *testing.T) { + tracker := &IntentTracker{} + tracker.Observe(NewKeyEvent(0, true, "Shift_L")) + tracker.Observe(NewKeyEvent(0, true, "Down")) + if got := tracker.RecentInput(); !got.Selecting || got.Intent != LineIntent { + t.Fatalf("Shift+Down = %+v", got) + } + tracker.Observe(NewKeyEvent(0, false, "Shift_L")) + tracker.Observe(NewKeyEvent(0, true, "Right")) + if got := tracker.RecentInput(); got.Selecting || got.Intent != CharacterIntent { + t.Fatalf("Right = %+v", got) + } +} diff --git a/internal/input/notification_commands_test.go b/internal/input/notification_commands_test.go new file mode 100644 index 0000000..7ab3ab2 --- /dev/null +++ b/internal/input/notification_commands_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestNotificationCommandBindingsMatchReference(t *testing.T) { + for name, bindings := range map[string]CommandBindings{ + "desktop": DefaultCommandBindings(DesktopKeyboardLayout), + "laptop": DefaultCommandBindings(LaptopKeyboardLayout), + "desktop browse": BrowseCommandBindings(DesktopKeyboardLayout), + "laptop browse": BrowseCommandBindings(LaptopKeyboardLayout), + } { + t.Run(name, func(t *testing.T) { + if got := bindings[CommandChord{Symbol: 'n', CthulhuModifier: true}]; got != PresentLastNotification { + t.Fatalf("Cthulhu+N = %q, want %q", got, PresentLastNotification) + } + if got := bindings[CommandChord{Symbol: 'n', Modifiers: controlModifier, CthulhuModifier: true}]; got != ShowNotificationList { + t.Fatalf("Cthulhu+Ctrl+N = %q, want %q", got, ShowNotificationList) + } + for chord, command := range bindings { + if command == PresentPreviousNotification || command == PresentNextNotification { + t.Fatalf("reference leaves %s unbound; found chord %#v", command, chord) + } + } + }) + } +} diff --git a/internal/input/ocr.go b/internal/input/ocr.go new file mode 100644 index 0000000..8660b94 --- /dev/null +++ b/internal/input/ocr.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +const ( + OCRObject Command = "ocr-object" + OCRWindow Command = "ocr-window" + OCRDesktop Command = "ocr-desktop" + OCRClipboard Command = "ocr-clipboard" + OCRPreviousCharacter Command = "ocr-previous-character" + OCRNextCharacter Command = "ocr-next-character" + OCRPreviousWord Command = "ocr-previous-word" + OCRNextWord Command = "ocr-next-word" + OCRPreviousLine Command = "ocr-previous-line" + OCRNextLine Command = "ocr-next-line" + OCRLineStart Command = "ocr-line-start" + OCRLineEnd Command = "ocr-line-end" + OCRDocumentStart Command = "ocr-document-start" + OCRDocumentEnd Command = "ocr-document-end" + OCRPreviousPage Command = "ocr-previous-page" + OCRNextPage Command = "ocr-next-page" + OCRSelectAll Command = "ocr-select-all" + OCRCopySelection Command = "ocr-copy-selection" + OCRActivate Command = "ocr-activate" + OCRRefresh Command = "ocr-refresh" + OCRDismiss Command = "ocr-dismiss" + OCRDismissAndPass Command = "ocr-dismiss-and-pass" +) + +// OCRPendingBindings claims cancellation only while capture/recognition runs. +func OCRPendingBindings(global CommandBindings) CommandBindings { + bindings := make(CommandBindings, len(global)+1) + for chord, command := range global { + bindings[chord] = command + } + bindings[CommandChord{Symbol: 0xff1b}] = OCRDismiss + return bindings +} + +// OCRCommandBindings overlays the temporary document controls on configured +// global commands. It never imports browser structural bindings. Chord retains +// Shift for the future selection owner; no extra persisted binding scope exists. +func OCRCommandBindings(global CommandBindings) CommandBindings { + bindings := make(CommandBindings, len(global)+32) + for chord, command := range global { + bindings[chord] = command + } + for _, binding := range []struct { + symbol uint32 + modifiers uint16 + command Command + }{ + {leftKeySymbol, 0, OCRPreviousCharacter}, {rightKeySymbol, 0, OCRNextCharacter}, + {leftKeySymbol, controlModifier, OCRPreviousWord}, {rightKeySymbol, controlModifier, OCRNextWord}, + {upKeySymbol, 0, OCRPreviousLine}, {downKeySymbol, 0, OCRNextLine}, + {0xff50, 0, OCRLineStart}, {0xff57, 0, OCRLineEnd}, + {0xff50, controlModifier, OCRDocumentStart}, {0xff57, controlModifier, OCRDocumentEnd}, + {0xff55, 0, OCRPreviousPage}, {0xff56, 0, OCRNextPage}, + } { + for _, shift := range []uint16{0, 1} { + bindings[CommandChord{Symbol: binding.symbol, Modifiers: binding.modifiers | shift}] = binding.command + } + } + bindings[CommandChord{Symbol: 'a', Modifiers: controlModifier}] = OCRSelectAll + bindings[CommandChord{Symbol: 'c', Modifiers: controlModifier}] = OCRCopySelection + bindings[CommandChord{Symbol: returnKeySymbol}] = OCRActivate + bindings[CommandChord{Symbol: ' '}] = OCRActivate + bindings[CommandChord{Symbol: 0xffc2, CthulhuModifier: true}] = OCRRefresh + bindings[CommandChord{Symbol: 0xff1b}] = OCRDismiss + bindings[CommandChord{Symbol: 0xff09}] = OCRDismissAndPass + bindings[CommandChord{Symbol: 0xff09, Modifiers: 1}] = OCRDismissAndPass + return bindings +} diff --git a/internal/input/ocr_test.go b/internal/input/ocr_test.go new file mode 100644 index 0000000..5783861 --- /dev/null +++ b/internal/input/ocr_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestOCRReaderShortcutsAndConfigurableSources(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + modifiers := uint16(0) + if layout == LaptopKeyboardLayout { + modifiers = 1 + } + bindings := DefaultCommandBindings(layout) + if bindings[CommandChord{Symbol: 'o', Modifiers: modifiers, CthulhuModifier: true}] != OCRWindow { + t.Fatal("missing primary OCR shortcut") + } + pending := OCRPendingBindings(bindings) + if pending[CommandChord{Symbol: downKeySymbol}] != "" || pending[CommandChord{Symbol: 0xff1b}] != OCRDismiss { + t.Fatal("pending OCR claimed navigation") + } + for _, command := range []Command{OCRObject, OCRWindow, OCRDesktop, OCRClipboard} { + if err := ValidateBindingOverrides([]BindingOverride{{Layout: layout, Scope: "global", Command: command, Keys: []string{"Cthulhu+F8"}}}); err != nil { + t.Fatal(err) + } + found := false + for _, row := range BindingRows(layout, nil) { + if row.Command == command && row.Scope == "global" { + found = true + if command == OCRWindow && row.DefaultKeys == "" { + t.Fatal("window OCR shortcut missing from preferences") + } + } + } + if !found { + t.Fatalf("missing configurable source %s", command) + } + } + } +} + +func TestOCRBindingsPreserveGlobalAndLaptopReview(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + global := DefaultCommandBindings(layout) + bindings := OCRCommandBindings(global) + if bindings[CommandChord{Symbol: 0xffc2, CthulhuModifier: true}] != OCRRefresh || + bindings[CommandChord{Symbol: 0xff55}] != OCRPreviousPage || bindings[CommandChord{Symbol: 0xff56}] != OCRNextPage { + t.Fatal("OCR refresh or page navigation binding missing") + } + for chord, command := range global { + if chord.CthulhuModifier && bindings[chord] != command { + t.Fatalf("global changed: %v", chord) + } + } + for _, symbol := range []uint32{'h', 'b', 'l', 'o', 'x'} { + if bindings[CommandChord{Symbol: symbol}] != "" { + t.Fatalf("OCR claimed structural key %c", symbol) + } + } + router := NewCommandRouter([]uint32{0xff63}, bindings) + for _, test := range []struct { + symbol uint32 + modifiers uint16 + want Command + }{ + {leftKeySymbol, 0, OCRPreviousCharacter}, {rightKeySymbol, 1 | controlModifier, OCRNextWord}, + {downKeySymbol, 0, OCRNextLine}, {'a', controlModifier, OCRSelectAll}, + {'c', controlModifier, OCRCopySelection}, {0xff09, 1, OCRDismissAndPass}, {0xff1b, 0, OCRDismiss}, + } { + consume, command := router.Handle(test.symbol, 42, test.modifiers, true) + if !consume || command != test.want { + t.Fatalf("chord got %v %s, want %s", consume, command, test.want) + } + consume, command = router.Handle(test.symbol, 42, test.modifiers, false) + if !consume || command != "" { + t.Fatal("lost held release") + } + } + if layout == LaptopKeyboardLayout && bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] != ReviewNextLine { + t.Fatal("laptop next line changed") + } + } +} diff --git a/internal/input/read_all_test.go b/internal/input/read_all_test.go new file mode 100644 index 0000000..8cf929d --- /dev/null +++ b/internal/input/read_all_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestReadAllBindings(t *testing.T) { + for _, browse := range []bool{false, true} { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + bindings := DefaultCommandBindings(layout) + if browse { + bindings = BrowseCommandBindings(layout) + } + symbol := uint32(0xffab) + modifier := layout == LaptopKeyboardLayout + if modifier { + symbol = ';' + } + if got := bindings[CommandChord{Symbol: symbol, CthulhuModifier: modifier}]; got != ReadAll { + t.Fatalf("layout %d browse %v: %q", layout, browse, got) + } + if bindings[CommandChord{Symbol: symbol, Modifiers: controlModifier, CthulhuModifier: modifier}] != "" { + t.Fatal("read all intercepted modified application shortcut") + } + } + } +} diff --git a/internal/input/repeat.go b/internal/input/repeat.go new file mode 100644 index 0000000..90fea6b --- /dev/null +++ b/internal/input/repeat.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +// RepeatTracker is owned by the native input producer. Classic X11 repeats +// release and press with the same timestamp; detectable autorepeat emits +// another press while the key remains down. Neither is a fresh physical press. +type RepeatTracker struct { + held [256]bool + releasedTimestamp [256]uint32 +} + +func (tracker *RepeatTracker) Observe(event Event) bool { + code := event.Keycode + if !event.Pressed { + tracker.held[code] = false + tracker.releasedTimestamp[code] = event.Timestamp + return false + } + repeated := tracker.held[code] || event.Timestamp != 0 && + tracker.releasedTimestamp[code] == event.Timestamp + tracker.held[code] = true + tracker.releasedTimestamp[code] = 0 + return repeated +} + +func (tracker *RepeatTracker) Reset() { + *tracker = RepeatTracker{} +} diff --git a/internal/input/repeat_test.go b/internal/input/repeat_test.go new file mode 100644 index 0000000..6650137 --- /dev/null +++ b/internal/input/repeat_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestRepeatTrackerRecognizesNativeSequences(t *testing.T) { + var tracker RepeatTracker + steps := []struct { + code uint8 + pressed bool + timestamp uint32 + repeat bool + }{ + {38, true, 100, false}, + {38, true, 110, true}, // Detectable autorepeat. + {39, true, 111, false}, // Another key is still a fresh press. + {38, false, 120, false}, + {38, true, 120, true}, // Classic X11 release/press pair. + {38, false, 130, false}, + {38, true, 140, false}, // Real release and new press. + {39, false, ^uint32(0), false}, + {39, true, 1, false}, // Timestamp wrap is not a repeat. + {38, false, 0, false}, + {38, true, 0, false}, // Unknown timestamps cannot prove a release/press pair. + {38, true, 0, true}, + } + for index, step := range steps { + if got := tracker.Observe(Event{Keycode: step.code, Pressed: step.pressed, Timestamp: step.timestamp}); got != step.repeat { + t.Fatalf("step %d: repeat = %t, want %t", index, got, step.repeat) + } + } + tracker.Reset() + if tracker.Observe(Event{Keycode: 38, Pressed: true, Timestamp: 150}) { + t.Fatal("secure reset retained held keys") + } +} diff --git a/internal/input/review_presses.go b/internal/input/review_presses.go new file mode 100644 index 0000000..3ad0f74 --- /dev/null +++ b/internal/input/review_presses.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "time" + +// The router owns this sequence across fully released chords and browse grab +// updates. Another key/chord, timeout, or the final press retires it. Held-key +// repeats never reach this helper. Reset only clears held keys, as for time/date. +func (router *CommandRouter) review_press(command Command, chord CommandChord) Command { + if command != ReviewCurrentCharacter && command != ReviewCurrentWord { + router.lastReviewPress = time.Time{} + return command + } + now := router.now() + if chord == router.lastReviewChord && !router.lastReviewPress.IsZero() && + now.Sub(router.lastReviewPress) >= 0 && now.Sub(router.lastReviewPress) <= 500*time.Millisecond { + router.reviewPresses++ + } else { + router.reviewPresses = 1 + } + router.lastReviewChord, router.lastReviewPress = chord, now + if command == ReviewCurrentCharacter && router.reviewPresses == 2 { + router.lastReviewPress = time.Time{} + return ReviewPhoneticCharacter + } + if command == ReviewCurrentWord { + switch router.reviewPresses { + case 2: + return ReviewSpellWord + case 3: + router.lastReviewPress = time.Time{} + return ReviewPhoneticWord + } + } + return command +} diff --git a/internal/input/speech_history_test.go b/internal/input/speech_history_test.go new file mode 100644 index 0000000..7e0ab7a --- /dev/null +++ b/internal/input/speech_history_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestSpeechHistoryBindingsInBothLayouts(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, bindings := range []CommandBindings{DefaultCommandBindings(layout), BrowseCommandBindings(layout)} { + if got := bindings[CommandChord{Symbol: 'h', Modifiers: controlModifier, CthulhuModifier: true}]; got != ShowSpeechHistory { + t.Fatalf("layout %d history = %q", layout, got) + } + if got := bindings[CommandChord{Symbol: 'd', Modifiers: 1, CthulhuModifier: true}]; got != ToggleSpeechMonitor { + t.Fatalf("layout %d monitor = %q", layout, got) + } + } + } +} diff --git a/internal/input/speech_mode_test.go b/internal/input/speech_mode_test.go new file mode 100644 index 0000000..ec1e189 --- /dev/null +++ b/internal/input/speech_mode_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestSpeechModeBindings(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, browse := range []bool{false, true} { + bindings, err := ResolveCommandBindings(layout, browse, nil) + if err != nil { + t.Fatal(err) + } + if got := bindings[CommandChord{Symbol: 's', CthulhuModifier: true}]; got != CycleSpeechMode { + t.Fatalf("layout %d browse %v: %q", layout, browse, got) + } + if got := bindings[CommandChord{Symbol: 's', Modifiers: 1 | controlModifier, CthulhuModifier: true}]; got != SaveSpeechSettings { + t.Fatalf("speech settings save changed: %q", got) + } + if browse && bindings[CommandChord{Symbol: 's'}] != DocumentNextSeparator { + t.Fatal("unmodified separator navigation changed") + } + custom, err := ResolveCommandBindings(layout, browse, []BindingOverride{{ + Layout: layout, Scope: "global", Command: CycleSpeechMode, Keys: []string{"Cthulhu+F8"}, + }}) + if err != nil { + t.Fatal(err) + } + if _, exists := custom[CommandChord{Symbol: 's', CthulhuModifier: true}]; exists { + t.Fatal("custom binding retained the old shortcut") + } + } + } +} diff --git a/internal/input/sticky_test.go b/internal/input/sticky_test.go new file mode 100644 index 0000000..1213644 --- /dev/null +++ b/internal/input/sticky_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import ( + "testing" + "time" +) + +func TestStickyPressesSurviveBrowseBindings(t *testing.T) { + router := NewCommandRouter([]uint32{0xff63}, BrowseCommandBindings(DesktopKeyboardLayout)) + now := time.Unix(100, 0) + router.now = func() time.Time { return now } + press := func(want Command) { + t.Helper() + router.Handle(0xff63, 118, 0, true) + if consumed, got := router.Handle('a', 38, 0, true); !consumed || got != want { + t.Fatalf("got %v %s want %s", consumed, got, want) + } + if _, got := router.Handle('a', 38, 0, true); got != "" { + t.Fatal("autorepeat changed mode") + } + router.Handle('a', 38, 0, false) + router.Handle(0xff63, 118, 0, false) + router.Reset() + now = now.Add(100 * time.Millisecond) + } + press(ToggleDocumentMode) + router.ReplaceBindings(DefaultCommandBindings(DesktopKeyboardLayout)) + press(StickyFocusMode) + press(StickyBrowseMode) + router.ReplaceBindings(BrowseCommandBindings(DesktopKeyboardLayout)) + now = now.Add(time.Second) + press(ToggleDocumentMode) + router.Handle('x', 53, 0, true) + router.Handle('x', 53, 0, false) + press(ToggleDocumentMode) +} + +func TestTableBindingsOnlyInBrowse(t *testing.T) { + for chord, command := range BrowseCommandBindings(DesktopKeyboardLayout) { + if chord.Modifiers != 9 { + continue + } + if DefaultCommandBindings(DesktopKeyboardLayout)[chord] != "" { + t.Fatal("table key grabbed outside browse") + } + router := NewCommandRouter(nil, BrowseCommandBindings(DesktopKeyboardLayout)) + if consumed, got := router.Handle(chord.Symbol, 100, 9, true); !consumed || got != command { + t.Fatalf("table binding %v %s", consumed, got) + } + } +} diff --git a/internal/input/tentacle_bindings.go b/internal/input/tentacle_bindings.go new file mode 100644 index 0000000..8be8e14 --- /dev/null +++ b/internal/input/tentacle_bindings.go @@ -0,0 +1,46 @@ +package input + +import ( + "fmt" + "strings" +) + +// Tentacle bindings are runtime registrations, never persisted core overrides. +// The native input owner arbitrates them again whenever core settings change. +func ValidateTentacleBindings(bindings []BindingOverride) error { + if len(bindings) > 16*32*4 { + return fmt.Errorf("too many tentacle bindings") + } + for _, binding := range bindings { + if !strings.HasPrefix(string(binding.Command), "tentacle/") || len(binding.Command) > 400 || + (binding.Layout != DesktopKeyboardLayout && binding.Layout != LaptopKeyboardLayout) || + (binding.Scope != "global" && binding.Scope != "browse") || len(binding.Keys) != 1 { + return fmt.Errorf("invalid tentacle binding") + } + if _, err := ParseCommandChord(binding.Keys[0]); err != nil { + return err + } + } + return nil +} + +// MergeTentacleBindings preserves core bindings and registration order. A +// conflicting suggestion stays unbound. Callers validate both inputs first. +func MergeTentacleBindings(core, suggestions []BindingOverride) []BindingOverride { + result := CloneBindingOverrides(core) + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + occupied, _ := ResolveCommandBindings(layout, true, core) + for _, suggestion := range suggestions { + if suggestion.Layout != layout { + continue + } + chord, _ := ParseCommandChord(suggestion.Keys[0]) + if occupied[chord] != "" { + continue + } + occupied[chord] = suggestion.Command + result = append(result, CloneBindingOverrides([]BindingOverride{suggestion})[0]) + } + } + return result +} diff --git a/internal/input/tentacle_bindings_test.go b/internal/input/tentacle_bindings_test.go new file mode 100644 index 0000000..b6694a8 --- /dev/null +++ b/internal/input/tentacle_bindings_test.go @@ -0,0 +1,62 @@ +package input + +import "testing" + +func TestTentacleWormholeShortcutAvailableOnBothLayouts(t *testing.T) { + chord, err := ParseCommandChord("Cthulhu+Ctrl+Shift+W") + if err != nil { + t.Fatal(err) + } + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + suggestions := []BindingOverride{{Layout: layout, Scope: "global", Command: "tentacle/wormhole/menu", Keys: []string{"Cthulhu+Ctrl+Shift+W"}}} + merged := MergeTentacleBindings(nil, suggestions) + bindings, err := ResolveCommandBindings(layout, true, merged) + if err != nil || bindings[chord] != "tentacle/wormhole/menu" { + t.Fatalf("layout %v: shortcut %v, error %v", layout, bindings[chord], err) + } + } +} + +func TestTentacleBindingsCoreWinsAcrossScopesAndRemapping(t *testing.T) { + suggestions := []BindingOverride{ + {DesktopKeyboardLayout, "global", "tentacle/a/one", []string{"F9"}}, + {DesktopKeyboardLayout, "browse", "tentacle/b/two", []string{"F9"}}, + {DesktopKeyboardLayout, "global", "tentacle/a/quit", []string{"Cthulhu+q"}}, + {LaptopKeyboardLayout, "global", "tentacle/b/two", []string{"F9"}}, + } + if err := ValidateTentacleBindings(suggestions); err != nil { + t.Fatal(err) + } + for _, core := range [][]BindingOverride{nil, {{DesktopKeyboardLayout, "browse", DocumentNextHeading, []string{"F9"}}}} { + merged := MergeTentacleBindings(core, suggestions) + bindings, err := ResolveCommandBindings(DesktopKeyboardLayout, true, merged) + if err != nil { + t.Fatal(err) + } + f9, _ := ParseCommandChord("F9") + want := Command("tentacle/a/one") + if core != nil { + want = DocumentNextHeading + } + if bindings[f9] != want { + t.Fatalf("F9 = %s want %s", bindings[f9], want) + } + quit, _ := ParseCommandChord("Cthulhu+q") + if bindings[quit] != Quit { + t.Fatal("tentacle replaced Quit") + } + laptop, _ := ResolveCommandBindings(LaptopKeyboardLayout, true, merged) + if laptop[f9] != "tentacle/b/two" { + t.Fatal("layouts share reservations") + } + } +} + +func TestTentacleBindingsRejectCoreIdentity(t *testing.T) { + if ValidateTentacleBindings([]BindingOverride{{DesktopKeyboardLayout, "global", Quit, []string{"F9"}}}) == nil { + t.Fatal("accepted core identity") + } + if ValidateBindingOverrides([]BindingOverride{{DesktopKeyboardLayout, "global", "tentacle/a/one", []string{"F9"}}}) == nil { + t.Fatal("persisted runtime registration") + } +} diff --git a/internal/input/version_test.go b/internal/input/version_test.go new file mode 100644 index 0000000..1bec55c --- /dev/null +++ b/internal/input/version_test.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestVersionChord(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout)) + router.Handle(0xff63, 118, 0, true) + if consumed, command := router.Handle('v', 55, 1, true); !consumed || command != PresentVersion { + t.Fatalf("layout %d: Cthulhu+Shift+V = %v %q", layout, consumed, command) + } + } +} diff --git a/internal/interaction/session.go b/internal/interaction/session.go new file mode 100644 index 0000000..110ea90 --- /dev/null +++ b/internal/interaction/session.go @@ -0,0 +1,467 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package interaction implements focus-free controls. It owns no keyboard, +// accessibility objects, output services or callbacks; the reader owns those. +package interaction + +import ( + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +type Kind uint8 + +const ( + List Kind = iota + 1 + Entry + CheckBox + Button +) + +type Item struct { + ID string `json:"id"` + Label string `json:"label"` + Disabled bool `json:"disabled,omitempty"` +} +type Element struct { + ID string `json:"id"` + Label string `json:"label"` + Kind Kind `json:"kind"` + Items []Item `json:"items,omitempty"` + Text string `json:"text,omitempty"` + Checked bool `json:"checked,omitempty"` + Disabled bool `json:"disabled,omitempty"` + Required bool `json:"required,omitempty"` + MaxLength int `json:"max_length,omitempty"` +} +type Form struct { + Title string `json:"title"` + Elements []Element `json:"elements"` +} +type Key uint8 + +const ( + Text Key = iota + Up + Down + Left + Right + Home + End + PageUp + PageDown + Tab + Enter + Escape + Backspace + Delete +) + +type Event struct { + Key Key + Text string + Shift, Control, Alt bool +} +type Presentation struct { + Speech, Braille string + Cursor int +} + +// Effect describes a host operation. Activation does not close the form: the +// caller can validate, replace it with another form, or finish the interaction. +type Effect struct { + Presentation + Activated string + Cancelled, Paste bool + Copy *string +} +type Value struct { + Text string `json:"text,omitempty"` + Selected string `json:"selected,omitempty"` + Checked bool `json:"checked,omitempty"` +} +type elementState struct { + element Element + selected, caret, anchor int + prefix string +} +type Session struct { + title string + elements []elementState + focus int + closed bool +} + +const maxTextLength = 8192 + +func NewSession(form Form) (*Session, error) { + if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > 64 { + return nil, errors.New("a titled form with 1 to 64 controls is required") + } + session := &Session{title: form.Title, focus: -1} + ids := map[string]bool{} + for _, element := range form.Elements { + if element.ID == "" || len(element.ID) > 1024 || element.Label == "" || len(element.Label) > 1024 || ids[element.ID] || element.Kind < List || element.Kind > Button { + return nil, errors.New("controls need unique IDs, labels and supported kinds") + } + ids[element.ID] = true + if element.MaxLength == 0 { + element.MaxLength = maxTextLength + } + if element.MaxLength < 1 || element.MaxLength > maxTextLength || len([]rune(element.Text)) > element.MaxLength { + return nil, errors.New("invalid entry length") + } + if !utf8.ValidString(element.Text) || strings.ContainsAny(element.Text, "\r\n\x00") { + return nil, errors.New("entries must contain single-line text") + } + for _, character := range element.Text { + if unicode.IsControl(character) { + return nil, errors.New("entries must contain single-line text") + } + } + element.Items = append([]Item(nil), element.Items...) + state := elementState{element: element, selected: -1, caret: len([]rune(element.Text)), anchor: len([]rune(element.Text))} + itemIDs := map[string]bool{} + if len(element.Items) > 256 { + return nil, errors.New("too many list items") + } + for index, item := range element.Items { + if item.ID == "" || len(item.ID) > 1024 || item.Label == "" || len(item.Label) > 1024 || itemIDs[item.ID] { + return nil, errors.New("list items need unique IDs and labels") + } + itemIDs[item.ID] = true + if state.selected < 0 && !item.Disabled { + state.selected = index + } + } + if element.Kind == List && state.selected < 0 { + state.element.Disabled = true + } + session.elements = append(session.elements, state) + if session.focus < 0 && !state.element.Disabled { + session.focus = len(session.elements) - 1 + } + } + if session.focus < 0 { + return nil, errors.New("form has no enabled controls") + } + return session, nil +} + +func (session *Session) Presentation() Presentation { + if session.closed { + return Presentation{} + } + state := &session.elements[session.focus] + element := state.element + text := element.Label + cursor := -1 + switch element.Kind { + case List: + text += fmt.Sprintf(": %s, %d of %d", element.Items[state.selected].Label, state.selected+1, len(element.Items)) + case Entry: + text += ", edit: " + element.Text + cursor = len([]rune(element.Label+", edit: ")) + state.caret + if state.caret != state.anchor { + text += fmt.Sprintf(" (selected %d characters)", abs(state.caret-state.anchor)) + } + case CheckBox: + if element.Checked { + text += ", checked" + } else { + text += ", unchecked" + } + case Button: + text += ", button" + } + speech := text + if element.Kind == List { + speech = element.Items[state.selected].Label + } + return Presentation{Speech: speech, Braille: text, Cursor: cursor} +} + +func (session *Session) Opening() Presentation { + output := session.Presentation() + if !session.closed && len(session.elements) > 1 && session.elements[session.focus].element.Kind == List { + output.Speech = session.elements[session.focus].element.Label + ". " + output.Speech + } + output.Speech = session.title + ". " + output.Speech + return output +} + +func (session *Session) Values() map[string]Value { + values := make(map[string]Value, len(session.elements)) + for _, state := range session.elements { + value := Value{Text: state.element.Text, Checked: state.element.Checked} + if state.element.Kind == List && state.selected >= 0 { + value.Selected = state.element.Items[state.selected].ID + } + values[state.element.ID] = value + } + return values +} + +func (session *Session) Handle(event Event) Effect { + if session.closed { + return Effect{} + } + if event.Key == Escape { + session.closed = true + return Effect{Cancelled: true} + } + if event.Key == Tab { + step := 1 + if event.Shift { + step = -1 + } + for range len(session.elements) { + session.focus = (session.focus + step + len(session.elements)) % len(session.elements) + if !session.elements[session.focus].element.Disabled { + break + } + } + output := session.Presentation() + // A list in a multi-control form needs its field label when Tab enters + // it. A menu's only control uses its opening title instead. + state := &session.elements[session.focus] + if len(session.elements) > 1 && state.element.Kind == List { + output.Speech = state.element.Label + ". " + output.Speech + } + return Effect{Presentation: output} + } + state := &session.elements[session.focus] + if event.Alt { + return Effect{} + } + if event.Key == Enter || state.element.Kind == Button && event.Key == Text && event.Text == " " && !event.Control { + for _, candidate := range session.elements { + if candidate.element.Kind == Entry && candidate.element.Required && !candidate.element.Disabled && strings.TrimSpace(candidate.element.Text) == "" { + return Effect{Presentation: Presentation{Speech: candidate.element.Label + " is required", Braille: candidate.element.Label + " is required", Cursor: -1}} + } + } + id := state.element.ID + if state.element.Kind == List { + id = state.element.Items[state.selected].ID + } + return Effect{Activated: id} + } + switch state.element.Kind { + case List: + step := 0 + switch event.Key { + case Up: + step = -1 + case Down: + step = 1 + case PageUp: + step = -10 + case PageDown: + step = 10 + case Home: + state.selected = 0 + step = 1 + case End: + state.selected = len(state.element.Items) - 1 + step = -1 + case Text: + if event.Control || event.Text == "" { + return Effect{} + } + prefix := state.prefix + strings.ToLower(event.Text) + if !state.find_prefix(prefix) { + prefix = strings.ToLower(event.Text) + state.find_prefix(prefix) + } + state.prefix = prefix + return Effect{Presentation: session.Presentation()} + default: + return Effect{} + } + state.prefix = "" + if event.Key == Home || event.Key == End { + for state.element.Items[state.selected].Disabled { + state.selected += step + } + } else { + for count := 0; count < abs(step); count++ { + previous := state.selected + for next := previous + sign(step); next >= 0 && next < len(state.element.Items); next += sign(step) { + if !state.element.Items[next].Disabled { + state.selected = next + break + } + } + } + } + case CheckBox: + if event.Key != Text || event.Text != " " || event.Control { + return Effect{} + } + state.element.Checked = !state.element.Checked + case Button: + return Effect{} + case Entry: + return session.edit(event) + } + return Effect{Presentation: session.Presentation()} +} + +func (state *elementState) find_prefix(prefix string) bool { + for offset := 1; offset <= len(state.element.Items); offset++ { + index := (state.selected + offset) % len(state.element.Items) + item := state.element.Items[index] + if !item.Disabled && strings.HasPrefix(strings.ToLower(item.Label), prefix) { + state.selected = index + return true + } + } + return false +} +func sign(value int) int { + if value < 0 { + return -1 + } + return 1 +} +func abs(value int) int { + if value < 0 { + return -value + } + return value +} + +func (session *Session) edit(event Event) Effect { + state := &session.elements[session.focus] + characters := []rune(state.element.Text) + start, end := min(state.caret, state.anchor), max(state.caret, state.anchor) + if event.Control && event.Key == Text { + switch strings.ToLower(event.Text) { + case "a": + state.anchor, state.caret = 0, len(characters) + output := session.Presentation() + output.Speech = "Selected all" + return Effect{Presentation: output} + case "v": + return Effect{Paste: true} + case "c", "x": + if start == end { + return Effect{} + } + text := string(characters[start:end]) + // Cutting is completed by the host only after clipboard write succeeds. + return Effect{Copy: &text} + default: + return Effect{} + } + } + if event.Key == Left || event.Key == Right || event.Key == Home || event.Key == End { + next := state.caret + switch event.Key { + case Left: + if !event.Shift && start != end { + next = start + } else { + next = max(0, next-1) + if event.Control { + for next > 0 && unicode.IsSpace(characters[next]) { + next-- + } + for next > 0 && !unicode.IsSpace(characters[next-1]) { + next-- + } + } + } + case Right: + if !event.Shift && start != end { + next = end + } else { + next = min(len(characters), next+1) + if event.Control { + for next < len(characters) && !unicode.IsSpace(characters[next]) { + next++ + } + for next < len(characters) && unicode.IsSpace(characters[next]) { + next++ + } + } + } + case Home: + next = 0 + case End: + next = len(characters) + } + state.caret = next + if !event.Shift { + state.anchor = next + } + output := session.Presentation() + if next == len(characters) { + output.Speech = "End of text" + } else { + output.Speech = string(characters[next]) + } + if event.Shift { + output.Speech += ", selected" + } + return Effect{Presentation: output} + } + if event.Key == Backspace || event.Key == Delete { + if start == end { + if event.Key == Backspace { + start = max(0, start-1) + } else { + end = min(len(characters), end+1) + } + } + if start == end { + return Effect{} + } + removed := string(characters[start:end]) + state.element.Text = string(append(characters[:start], characters[end:]...)) + state.caret, state.anchor = start, start + output := session.Presentation() + output.Speech = "Deleted " + removed + return Effect{Presentation: output} + } + if event.Key == Text && event.Text != "" && !event.Control { + return session.Insert(event.Text) + } + return Effect{} +} + +// Insert handles typing and paste atomically; invalid/oversized input leaves +// the field and selection untouched. +func (session *Session) Insert(text string) Effect { + if session.closed { + return Effect{} + } + state := &session.elements[session.focus] + if state.element.Kind != Entry { + return Effect{} + } + if !utf8.ValidString(text) { + return Effect{Presentation: Presentation{Speech: "Invalid text", Braille: "Invalid text", Cursor: -1}} + } + characters := []rune(state.element.Text) + start, end := min(state.caret, state.anchor), max(state.caret, state.anchor) + inserted := []rune(text) + for _, character := range inserted { + if unicode.IsControl(character) { + return Effect{Presentation: Presentation{Speech: "Only single-line text is accepted", Braille: "Only single-line text is accepted", Cursor: -1}} + } + } + if len(characters)-(end-start)+len(inserted) > state.element.MaxLength { + return Effect{Presentation: Presentation{Speech: "Text limit reached", Braille: "Text limit reached", Cursor: -1}} + } + state.element.Text = string(characters[:start]) + text + string(characters[end:]) + state.caret, state.anchor = start+len(inserted), start+len(inserted) + output := session.Presentation() + output.Speech = text + return Effect{Presentation: output} +} diff --git a/internal/interaction/session_test.go b/internal/interaction/session_test.go new file mode 100644 index 0000000..37b2e1f --- /dev/null +++ b/internal/interaction/session_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package interaction + +import ( + "strings" + "testing" +) + +func TestListNavigationAndIndependentSessions(t *testing.T) { + form := Form{Title: "Actions", Elements: []Element{{ID: "actions", Label: "Actions", Kind: List, Items: []Item{{ID: "click", Label: "Click"}, {ID: "skip", Label: "Unavailable", Disabled: true}, {ID: "press", Label: "Press"}, {ID: "ancestor", Label: "Click ancestor"}}}}} + first, err := NewSession(form) + if err != nil { + t.Fatal(err) + } + second, _ := NewSession(form) + if opening := first.Opening().Speech; opening != "Actions. Click" { + t.Fatalf("opening speech = %q", opening) + } + if moved := first.Handle(Event{Key: Down}).Speech; moved != "Press" { + t.Fatalf("option speech = %q", moved) + } + if effect := first.Handle(Event{Key: Enter}); effect.Activated != "press" { + t.Fatalf("selection = %+v", effect) + } + if second.Handle(Event{Key: Enter}).Activated != "click" { + t.Fatal("sessions share state") + } + first.Handle(Event{Key: End}) + first.Handle(Event{Key: Down}) + if first.Handle(Event{Key: Enter}).Activated != "ancestor" { + t.Fatal("boundary moved selection") + } + first.Handle(Event{Key: Home}) + first.Handle(Event{Text: "p"}) + if first.Handle(Event{Key: Enter}).Activated != "press" { + t.Fatal("typeahead failed") + } + if !first.Handle(Event{Key: Escape}).Cancelled || first.Handle(Event{Key: Enter}).Activated != "" { + t.Fatal("cancelled session remained usable") + } + form.Elements[0].Items[0].Label = "Changed" + if !strings.Contains(second.Presentation().Speech, "Click") { + t.Fatal("caller mutation changed session") + } +} + +func TestCallerSuppliedMenuTitleAndFormListLabel(t *testing.T) { + menu, err := NewSession(Form{Title: "Wormhole Menu", Elements: []Element{{ID: "choices", Label: "Options", Kind: List, Items: []Item{{ID: "send", Label: "Send focused item"}, {ID: "receive", Label: "Receive file"}}}}}) + if err != nil { + t.Fatal(err) + } + if opening := menu.Opening().Speech; opening != "Wormhole Menu. Send focused item" { + t.Fatalf("custom title = %q", opening) + } + if moved := menu.Handle(Event{Key: Down}).Speech; moved != "Receive file" { + t.Fatalf("custom menu option = %q", moved) + } + if focus := menu.Handle(Event{Key: Tab}).Speech; focus != "Receive file" { + t.Fatalf("menu repeated title on Tab: %q", focus) + } + form, _ := NewSession(Form{Title: "Example", Elements: []Element{{ID: "code", Label: "Code", Kind: Entry}, {ID: "method", Label: "Method", Kind: List, Items: []Item{{ID: "send", Label: "Send"}}}}}) + if focus := form.Handle(Event{Key: Tab}).Speech; focus != "Method. Send" { + t.Fatalf("form list lost its field label: %q", focus) + } +} + +func TestFormTraversalEditingAndValidation(t *testing.T) { + session, err := NewSession(Form{Title: "Receive", Elements: []Element{{ID: "code", Label: "Code", Kind: Entry, Required: true, MaxLength: 8}, {ID: "disabled", Label: "Disabled", Kind: Button, Disabled: true}, {ID: "keep", Label: "Keep", Kind: CheckBox}, {ID: "submit", Label: "Receive", Kind: Button}}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(session.Handle(Event{Key: Enter}).Speech, "required") { + t.Fatal("missing required validation") + } + session.Handle(Event{Text: "ab猫"}) + session.Handle(Event{Key: Left, Shift: true}) + session.Insert("é") + if session.Values()["code"].Text != "abé" { + t.Fatal("Unicode selection replacement") + } + session.Handle(Event{Key: Home}) + session.Handle(Event{Key: Delete}) + if session.Values()["code"].Text != "bé" { + t.Fatal("Unicode deletion") + } + session.Handle(Event{Text: "a", Control: true}) + if effect := session.Handle(Event{Text: "c", Control: true}); effect.Copy == nil || *effect.Copy != "bé" { + t.Fatal("copy selection") + } + if !session.Handle(Event{Text: "v", Control: true}).Paste { + t.Fatal("paste host request missing") + } + before := session.Values()["code"].Text + if !strings.Contains(session.Insert("too long to fit").Speech, "limit") || session.Values()["code"].Text != before { + t.Fatal("oversized insertion changed field") + } + if !strings.Contains(session.Insert("bad\ntext").Speech, "single-line") { + t.Fatal("multiline paste accepted") + } + if session.Insert(string([]byte{0xff})).Speech != "Invalid text" || session.Values()["code"].Text != before { + t.Fatal("invalid UTF-8 paste changed entry") + } + session.Handle(Event{Key: Tab}) + session.Handle(Event{Text: " "}) + if !session.Values()["keep"].Checked { + t.Fatal("checkbox not toggled / disabled control took focus") + } + session.Handle(Event{Key: Tab}) + if session.Handle(Event{Key: Enter}).Activated != "submit" { + t.Fatal("button did not submit") + } + session.Handle(Event{Key: Tab}) + if !strings.Contains(session.Presentation().Speech, "Code") { + t.Fatal("Tab did not wrap") + } + session.Handle(Event{Key: Tab, Shift: true}) + if session.Handle(Event{Key: Enter}).Activated != "submit" { + t.Fatal("Shift Tab did not wrap") + } +} + +func TestWordMovementAndSelection(t *testing.T) { + session, _ := NewSession(Form{Title: "Edit", Elements: []Element{{ID: "text", Label: "Text", Kind: Entry, Text: "one two 猫"}}}) + session.Handle(Event{Key: Left, Control: true, Shift: true}) + if effect := session.Handle(Event{Text: "c", Control: true}); effect.Copy == nil || *effect.Copy != "猫" { + t.Fatal("word selection") + } + session.Handle(Event{Key: Backspace}) + if session.Values()["text"].Text != "one two " { + t.Fatal("word selection deletion") + } + session.Handle(Event{Key: Home}) + session.Handle(Event{Key: Right, Control: true}) + session.Insert("é") + if session.Values()["text"].Text != "one étwo " { + t.Fatalf("word movement = %+v", session.Values()) + } +} + +func TestInvalidForms(t *testing.T) { + for _, form := range []Form{{}, {Title: "Empty", Elements: []Element{{ID: "list", Label: "List", Kind: List}}}, {Title: "Duplicate", Elements: []Element{{ID: "same", Label: "First", Kind: Button}, {ID: "same", Label: "Second", Kind: Entry}}}} { + if _, err := NewSession(form); err == nil { + t.Fatalf("accepted invalid form %+v", form) + } + } +} + +func TestButtonActivationValidatesRequiredEntries(t *testing.T) { + for _, event := range []Event{{Key: Enter}, {Key: Text, Text: " "}} { + t.Run(map[Key]string{Enter: "Enter", Text: "Space"}[event.Key], func(t *testing.T) { + session, err := NewSession(Form{Title: "Receive", Elements: []Element{ + {ID: "code", Label: "Code", Kind: Entry, Required: true}, + {ID: "submit", Label: "Receive", Kind: Button}, + }}) + if err != nil { + t.Fatal(err) + } + session.Handle(Event{Key: Tab}) + if effect := session.Handle(event); effect.Activated != "" || effect.Speech != "Code is required" { + t.Fatalf("empty required field accepted: %+v", effect) + } + session.Handle(Event{Key: Tab, Shift: true}) + session.Insert("1234") + session.Handle(Event{Key: Tab}) + if effect := session.Handle(event); effect.Activated != "submit" { + t.Fatalf("completed form rejected: %+v", effect) + } + }) + } +} diff --git a/internal/lifecycle/lifecycle.go b/internal/lifecycle/lifecycle.go new file mode 100644 index 0000000..b22e97a --- /dev/null +++ b/internal/lifecycle/lifecycle.go @@ -0,0 +1,154 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package lifecycle + +import ( + "context" + "errors" + "fmt" + "log/slog" +) + +type State string + +const ( + StateNew State = "New" + StateStarting State = "Starting" + StateRunning State = "Running" + StateStopping State = "Stopping" + StateStopped State = "Stopped" + StateFailed State = "Failed" +) + +type Service struct { + Name string + Start func(context.Context) error + Stop func(context.Context) error +} + +type Runner struct { + state State + services []Service + started []int + logger *slog.Logger +} + +func New(services ...Service) (*Runner, error) { + return NewWithLogger(nil, services...) +} + +func NewWithLogger(logger *slog.Logger, services ...Service) (*Runner, error) { + names := make(map[string]struct{}, len(services)) + for index, service := range services { + switch { + case service.Name == "": + return nil, fmt.Errorf("service %d has no name", index) + case service.Start == nil: + return nil, fmt.Errorf("service %q has no start function", service.Name) + case service.Stop == nil: + return nil, fmt.Errorf("service %q has no stop function", service.Name) + } + if _, exists := names[service.Name]; exists { + return nil, fmt.Errorf("duplicate service name %q", service.Name) + } + names[service.Name] = struct{}{} + } + return &Runner{ + state: StateNew, + services: append([]Service(nil), services...), + logger: logger, + }, nil +} + +func (runner *Runner) State() State { + return runner.state +} + +func (runner *Runner) Start(ctx context.Context) error { + if ctx == nil { + return errors.New("lifecycle start requires a non-nil context") + } + switch runner.state { + case StateNew: + case StateRunning, StateStarting, StateStopping, StateStopped, StateFailed: + return fmt.Errorf("cannot start from state %s", runner.state) + } + + runner.state = StateStarting + for index, service := range runner.services { + if err := ctx.Err(); err != nil { + return runner.failStartup(fmt.Errorf("start %q canceled: %w", service.Name, err)) + } + if err := service.Start(ctx); err != nil { + return runner.failStartup(fmt.Errorf("start %q: %w", service.Name, err)) + } + runner.started = append(runner.started, index) + } + runner.state = StateRunning + return nil +} + +func (runner *Runner) Stop(ctx context.Context) error { + if ctx == nil { + return errors.New("lifecycle stop requires a non-nil context") + } + switch runner.state { + case StateNew: + runner.state = StateStopped + return nil + case StateRunning: + case StateStopped: + return nil + case StateStarting, StateStopping, StateFailed: + return fmt.Errorf("cannot stop from state %s", runner.state) + } + + runner.state = StateStopping + stopError := runner.stopStartedServices(ctx) + runner.state = StateStopped + return stopError +} + +func (runner *Runner) failStartup(startError error) error { + runner.state = StateFailed + runner.state = StateStopping + stopError := runner.stopStartedServices(context.Background()) + runner.state = StateStopped + return errors.Join(startError, stopError) +} + +func (runner *Runner) stopStartedServices(ctx context.Context) error { + var stopErrors []error + for index := len(runner.started) - 1; index >= 0; index-- { + serviceIndex := runner.started[index] + service := runner.services[serviceIndex] + if runner.logger != nil { + runner.logger.Debug("lifecycle service stopping", "service", service.Name) + } + if err := service.Stop(ctx); err != nil { + stopErrors = append(stopErrors, fmt.Errorf("stop %q: %w", service.Name, err)) + if runner.logger != nil { + runner.logger.Warn("lifecycle service stop failed", "service", service.Name, "error", err) + } + } else if runner.logger != nil { + runner.logger.Debug("lifecycle service stopped", "service", service.Name) + } + } + runner.started = nil + return errors.Join(stopErrors...) +} diff --git a/internal/lifecycle/lifecycle_test.go b/internal/lifecycle/lifecycle_test.go new file mode 100644 index 0000000..d3f9f39 --- /dev/null +++ b/internal/lifecycle/lifecycle_test.go @@ -0,0 +1,176 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package lifecycle + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" +) + +func TestStartAndStopReverseOrder(t *testing.T) { + var events []string + runner, err := New( + Service{ + Name: "first", + Start: func(context.Context) error { events = append(events, "start first"); return nil }, + Stop: func(context.Context) error { events = append(events, "stop first"); return nil }, + }, + Service{ + Name: "second", + Start: func(context.Context) error { events = append(events, "start second"); return nil }, + Stop: func(context.Context) error { events = append(events, "stop second"); return nil }, + }, + ) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := runner.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if runner.State() != StateRunning { + t.Fatalf("state after start = %s, want %s", runner.State(), StateRunning) + } + if err := runner.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + want := strings.Join([]string{"start first", "start second", "stop second", "stop first"}, ",") + if got := strings.Join(events, ","); got != want { + t.Fatalf("events = %q, want %q", got, want) + } + if runner.State() != StateStopped { + t.Fatalf("state after stop = %s, want %s", runner.State(), StateStopped) + } +} + +func TestStartupFailureStopsStartedServices(t *testing.T) { + startFailure := errors.New("start failed") + stopFailure := errors.New("stop failed") + var stopped bool + runner, err := New( + Service{ + Name: "ready", + Start: func(context.Context) error { return nil }, + Stop: func(context.Context) error { + stopped = true + return stopFailure + }, + }, + Service{ + Name: "broken", + Start: func(context.Context) error { return startFailure }, + Stop: func(context.Context) error { return nil }, + }, + ) + if err != nil { + t.Fatalf("New: %v", err) + } + + err = runner.Start(context.Background()) + if !errors.Is(err, startFailure) || !errors.Is(err, stopFailure) { + t.Fatalf("Start error = %v, want both start and stop failures", err) + } + if !stopped { + t.Fatal("started service was not stopped") + } + if runner.State() != StateStopped { + t.Fatalf("state after failed start = %s, want %s", runner.State(), StateStopped) + } +} + +func TestCanceledStartDoesNotCallService(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var called bool + runner, err := New(Service{ + Name: "service", + Start: func(context.Context) error { + called = true + return nil + }, + Stop: func(context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + err = runner.Start(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start error = %v, want context.Canceled", err) + } + if called { + t.Fatal("service start was called after cancellation") + } + if runner.State() != StateStopped { + t.Fatalf("state after canceled start = %s, want %s", runner.State(), StateStopped) + } +} + +func TestStopIsIdempotent(t *testing.T) { + runner, err := New(Service{ + Name: "service", + Start: func(context.Context) error { return nil }, + Stop: func(context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := runner.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if err := runner.Stop(context.Background()); err != nil { + t.Fatalf("first Stop: %v", err) + } + if err := runner.Stop(context.Background()); err != nil { + t.Fatalf("second Stop: %v", err) + } +} + +func TestStopLogsCompletedServiceStages(t *testing.T) { + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + runner, err := NewWithLogger(logger, Service{ + Name: "service", + Start: func(context.Context) error { return nil }, + Stop: func(context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewWithLogger: %v", err) + } + if err := runner.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if err := runner.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + + output := logs.String() + if !strings.Contains(output, "lifecycle service stopping") || + !strings.Contains(output, "lifecycle service stopped") || + !strings.Contains(output, "service") { + t.Fatalf("stop logs = %q, want stopping and stopped records for service", output) + } +} + +func TestNewRejectsInvalidService(t *testing.T) { + if _, err := New(Service{Name: "invalid"}); err == nil { + t.Fatal("New accepted a service without functions") + } +} diff --git a/internal/logging/async.go b/internal/logging/async.go new file mode 100644 index 0000000..ebc63a9 --- /dev/null +++ b/internal/logging/async.go @@ -0,0 +1,399 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package logging + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + "sync/atomic" + "time" +) + +const ( + defaultDiagnosticsCapacity = 256 + defaultStopTimeout = 2 * time.Second + defaultCancelGrace = 100 * time.Millisecond +) + +type DiagnosticsWriter interface { + Write([]byte) (int, error) + Cancel() error + Close() error +} + +type AsyncOptions struct { + Options + Capacity int + StopTimeout time.Duration + CancelGrace time.Duration +} + +type diagnosticRecord struct { + data []byte + critical bool +} + +type Diagnostics struct { + writer DiagnosticsWriter + formatter *Handler + logger *slog.Logger + queue []diagnosticRecord + wake chan struct{} + stopSignal chan struct{} + done chan struct{} + mutex sync.Mutex + stopOnce sync.Once + errorsMutex sync.Mutex + stopMutex sync.Mutex + writeErrors []error + stopError error + stopSet bool + dropped atomic.Uint64 + reported atomic.Uint64 + capacity int + stopTimeout time.Duration + cancelGrace time.Duration +} + +func NewAsync(writer DiagnosticsWriter, options AsyncOptions) *Diagnostics { + if writer == nil { + writer = NewPassthroughWriter(nil) + } + if options.Capacity <= 0 { + options.Capacity = defaultDiagnosticsCapacity + } + if options.StopTimeout <= 0 { + options.StopTimeout = defaultStopTimeout + } + if options.CancelGrace <= 0 { + options.CancelGrace = defaultCancelGrace + } + diagnostics := &Diagnostics{ + writer: writer, + formatter: newHandler(nil, options.Options), + queue: make([]diagnosticRecord, 0, options.Capacity), + wake: make(chan struct{}, 1), + stopSignal: make(chan struct{}), + done: make(chan struct{}), + capacity: options.Capacity, + stopTimeout: options.StopTimeout, + cancelGrace: options.CancelGrace, + } + diagnostics.logger = slog.New(&asyncHandler{ + diagnostics: diagnostics, + base: diagnostics.formatter, + }) + go diagnostics.run() + return diagnostics +} + +func (diagnostics *Diagnostics) Logger() *slog.Logger { + return diagnostics.logger +} + +func (diagnostics *Diagnostics) Dropped() uint64 { + return diagnostics.dropped.Load() +} + +func (diagnostics *Diagnostics) Stop(ctx context.Context) error { + if ctx == nil { + return errors.New("diagnostics stop requires a non-nil context") + } + diagnostics.signalStop() + + stopContext, cancel := context.WithTimeout(ctx, diagnostics.stopTimeout) + defer cancel() + if !diagnostics.waitOrCancel(stopContext) { + diagnostics.setStopError(fmt.Errorf("flush diagnostics: %w", stopContext.Err())) + return diagnostics.stopErrorValue() + } + diagnostics.setStopError(errors.Join(diagnostics.writeErrorsValue()...)) + return diagnostics.stopErrorValue() +} + +func (diagnostics *Diagnostics) signalStop() { + diagnostics.stopOnce.Do(func() { + close(diagnostics.stopSignal) + diagnostics.wakeup() + }) +} + +func (diagnostics *Diagnostics) waitOrCancel(ctx context.Context) bool { + deadline, hasDeadline := ctx.Deadline() + if hasDeadline { + cancelDelay := time.Until(deadline) - diagnostics.cancelGrace + if cancelDelay > 0 { + timer := time.NewTimer(cancelDelay) + select { + case <-diagnostics.done: + timer.Stop() + return true + case <-timer.C: + } + } + } else { + select { + case <-diagnostics.done: + return true + case <-time.After(diagnostics.cancelGrace): + } + } + if err := diagnostics.writer.Cancel(); err != nil { + diagnostics.appendWriteError(fmt.Errorf("cancel diagnostics writer: %w", err)) + } + select { + case <-diagnostics.done: + return true + case <-ctx.Done(): + return false + } +} + +func (diagnostics *Diagnostics) run() { + defer close(diagnostics.done) + for { + diagnostics.mutex.Lock() + if len(diagnostics.queue) == 0 { + if !diagnostics.accepting() { + diagnostics.mutex.Unlock() + diagnostics.reportDrops() + diagnostics.mutex.Lock() + if len(diagnostics.queue) == 0 { + diagnostics.mutex.Unlock() + break + } + } else { + wake := diagnostics.wake + diagnostics.mutex.Unlock() + select { + case <-wake: + case <-diagnostics.stopSignal: + } + continue + } + } + record := diagnostics.queue[0] + diagnostics.queue = diagnostics.queue[1:] + diagnostics.mutex.Unlock() + + if _, err := diagnostics.writer.Write(record.data); err != nil { + diagnostics.appendWriteError(fmt.Errorf("write diagnostics: %w", err)) + } + diagnostics.reportDrops() + } + if err := diagnostics.writer.Close(); err != nil { + diagnostics.appendWriteError(fmt.Errorf("close diagnostics writer: %w", err)) + } +} + +func (diagnostics *Diagnostics) accepting() bool { + select { + case <-diagnostics.stopSignal: + return false + default: + return true + } +} + +func (diagnostics *Diagnostics) enqueue(record diagnosticRecord) { + diagnostics.mutex.Lock() + if !diagnostics.accepting() { + diagnostics.mutex.Unlock() + diagnostics.dropped.Add(1) + return + } + if len(diagnostics.queue) == diagnostics.capacity { + diagnostics.dropped.Add(1) + var admitted bool + diagnostics.queue, admitted = makeRoom(diagnostics.queue, record.critical) + if !admitted { + diagnostics.mutex.Unlock() + return + } + } + diagnostics.queue = append(diagnostics.queue, record) + diagnostics.mutex.Unlock() + diagnostics.wakeup() +} + +func makeRoom(queue []diagnosticRecord, incomingCritical bool) ([]diagnosticRecord, bool) { + for index, record := range queue { + if !record.critical { + copy(queue[index:], queue[index+1:]) + queue[len(queue)-1] = diagnosticRecord{} + return queue[:len(queue)-1], true + } + } + if !incomingCritical { + return queue, false + } + copy(queue, queue[1:]) + queue[len(queue)-1] = diagnosticRecord{} + return queue[:len(queue)-1], true +} + +func (diagnostics *Diagnostics) reportDrops() { + total := diagnostics.dropped.Load() + reported := diagnostics.reported.Swap(total) + if total == reported { + return + } + record := slog.NewRecord(time.Now(), slog.LevelWarn, "diagnostics queue overflow", 0) + record.AddAttrs( + slog.Uint64("dropped_messages", total-reported), + slog.Uint64("total_dropped_messages", total), + ) + if _, err := diagnostics.writer.Write(diagnostics.formatter.format(record)); err != nil { + diagnostics.appendWriteError(fmt.Errorf("write diagnostics overflow report: %w", err)) + } +} + +func (diagnostics *Diagnostics) wakeup() { + select { + case diagnostics.wake <- struct{}{}: + default: + } +} + +func (diagnostics *Diagnostics) appendWriteError(err error) { + diagnostics.errorsMutex.Lock() + defer diagnostics.errorsMutex.Unlock() + diagnostics.writeErrors = append(diagnostics.writeErrors, err) +} + +func (diagnostics *Diagnostics) writeErrorsValue() []error { + diagnostics.errorsMutex.Lock() + defer diagnostics.errorsMutex.Unlock() + return append([]error(nil), diagnostics.writeErrors...) +} + +func (diagnostics *Diagnostics) setStopError(err error) { + diagnostics.stopMutex.Lock() + defer diagnostics.stopMutex.Unlock() + if !diagnostics.stopSet { + diagnostics.stopError = err + diagnostics.stopSet = true + } +} + +func (diagnostics *Diagnostics) stopErrorValue() error { + diagnostics.stopMutex.Lock() + defer diagnostics.stopMutex.Unlock() + return diagnostics.stopError +} + +type asyncHandler struct { + diagnostics *Diagnostics + base *Handler +} + +func (handler *asyncHandler) Enabled(ctx context.Context, level slog.Level) bool { + return handler.base.Enabled(ctx, level) +} + +func (handler *asyncHandler) Handle(_ context.Context, record slog.Record) error { + if !handler.base.Enabled(context.Background(), record.Level) { + return nil + } + handler.diagnostics.enqueue(diagnosticRecord{ + data: handler.base.format(record), + critical: record.Level >= slog.LevelWarn, + }) + return nil +} + +func (handler *asyncHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + clone := *handler + clone.base = handler.base.WithAttrs(attrs).(*Handler) + return &clone +} + +func (handler *asyncHandler) WithGroup(name string) slog.Handler { + clone := *handler + clone.base = handler.base.WithGroup(name).(*Handler) + return &clone +} + +type passthroughWriter struct { + writer io.Writer +} + +func NewPassthroughWriter(writer io.Writer) DiagnosticsWriter { + if writer == nil { + writer = io.Discard + } + return passthroughWriter{writer: writer} +} + +func (writer passthroughWriter) Write(data []byte) (int, error) { + return writer.writer.Write(data) +} + +func (writer passthroughWriter) Cancel() error { return nil } + +func (writer passthroughWriter) Close() error { return nil } + +type fileWriter struct { + file *os.File + fallback io.Writer + closeOnce sync.Once +} + +func OpenFile(path string, fallback io.Writer) (DiagnosticsWriter, error) { + if path == "" || path == "." || path == ".." { + return nil, errors.New("invalid diagnostics file path") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + return nil, fmt.Errorf("open diagnostics file: %w", err) + } + return &fileWriter{file: file, fallback: fallback}, nil +} + +func (writer *fileWriter) Write(data []byte) (int, error) { + if writer.fallback != nil { + if _, err := writer.fallback.Write(data); err != nil { + return 0, fmt.Errorf("write diagnostics fallback: %w", err) + } + } + return writer.file.Write(data) +} + +func (writer *fileWriter) Cancel() error { + return writer.close() +} + +func (writer *fileWriter) Close() error { + return writer.close() +} + +func (writer *fileWriter) close() error { + var closeError error + writer.closeOnce.Do(func() { + closeError = writer.file.Close() + }) + if errors.Is(closeError, os.ErrClosed) { + return nil + } + return closeError +} diff --git a/internal/logging/async_test.go b/internal/logging/async_test.go new file mode 100644 index 0000000..fcbab77 --- /dev/null +++ b/internal/logging/async_test.go @@ -0,0 +1,334 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package logging + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +type controlledWriter struct { + mutex sync.Mutex + output bytes.Buffer + started chan struct{} + startOnce sync.Once + release chan struct{} + canceled chan struct{} + cancelOnce sync.Once + writeError error + autoRelease bool + closed bool +} + +func newControlledWriter() *controlledWriter { + return &controlledWriter{ + started: make(chan struct{}), + release: make(chan struct{}, 1), + canceled: make(chan struct{}), + } +} + +func (writer *controlledWriter) Write(data []byte) (int, error) { + writer.startOnce.Do(func() { close(writer.started) }) + select { + case <-writer.release: + case <-writer.canceled: + return 0, errors.New("diagnostics writer canceled") + } + if writer.autoRelease { + select { + case writer.release <- struct{}{}: + default: + } + } + writer.mutex.Lock() + defer writer.mutex.Unlock() + if writer.writeError != nil { + return 0, writer.writeError + } + return writer.output.Write(data) +} + +func (writer *controlledWriter) Cancel() error { + writer.cancelOnce.Do(func() { close(writer.canceled) }) + return nil +} + +func (writer *controlledWriter) Close() error { + writer.mutex.Lock() + defer writer.mutex.Unlock() + writer.closed = true + return nil +} + +func (writer *controlledWriter) releaseWrites(count int) { + for index := 0; index < count; index++ { + writer.release <- struct{}{} + } +} + +func (writer *controlledWriter) lines() []string { + writer.mutex.Lock() + defer writer.mutex.Unlock() + return strings.Split(strings.TrimSpace(writer.output.String()), "\n") +} + +func messageNames(t *testing.T, lines []string) []string { + t.Helper() + messages := make([]string, 0, len(lines)) + for _, line := range lines { + if line == "" { + continue + } + var record map[string]any + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatalf("invalid diagnostics JSON %q: %v", line, err) + } + messages = append(messages, fmt.Sprint(record["message"])) + } + return messages +} + +func TestAsyncLoggingDoesNotBlockOnBlockedWriter(t *testing.T) { + writer := newControlledWriter() + diagnostics := NewAsync(writer, AsyncOptions{Options: Options{Level: slog.LevelDebug}, Capacity: 4, StopTimeout: 100 * time.Millisecond, CancelGrace: 10 * time.Millisecond}) + logger := diagnostics.Logger() + completed := make(chan struct{}) + go func() { + logger.Debug("accessibility event") + close(completed) + }() + + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("slog call blocked on diagnostics writer") + } + if diagnostics.Dropped() != 0 { + t.Fatalf("dropped messages = %d, want 0", diagnostics.Dropped()) + } + writer.Cancel() + if err := diagnostics.Stop(context.Background()); err == nil || !strings.Contains(err.Error(), "diagnostics writer canceled") { + t.Fatalf("Stop error = %v, want canceled writer error", err) + } +} + +func TestAsyncLoggingFlushesInOrder(t *testing.T) { + writer := newControlledWriter() + diagnostics := NewAsync(writer, AsyncOptions{Capacity: 8, StopTimeout: time.Second, CancelGrace: 10 * time.Millisecond}) + logger := diagnostics.Logger() + for _, message := range []string{"first", "second", "third", "fourth"} { + logger.Info(message) + } + writer.releaseWrites(4) + + if err := diagnostics.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + got := messageNames(t, writer.lines()) + want := []string{"first", "second", "third", "fourth"} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("messages = %v, want %v", got, want) + } +} + +func TestAsyncLoggingSaturationKeepsCriticalMessagesAndReportsDrops(t *testing.T) { + writer := newControlledWriter() + writer.autoRelease = true + diagnostics := NewAsync(writer, AsyncOptions{Options: Options{Level: slog.LevelDebug}, Capacity: 2, StopTimeout: time.Second, CancelGrace: 10 * time.Millisecond}) + logger := diagnostics.Logger() + logger.Debug("debug 1") + select { + case <-writer.started: + case <-time.After(time.Second): + t.Fatal("diagnostics writer did not start") + } + logger.Debug("debug 2") + logger.Debug("debug 3") + logger.Error("critical message") + writer.releaseWrites(1) + + if err := diagnostics.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + messages := messageNames(t, writer.lines()) + joined := strings.Join(messages, "\n") + if !strings.Contains(joined, "critical message") { + t.Fatalf("critical message was dropped: %q", joined) + } + if strings.Contains(joined, "debug 2") { + t.Fatalf("oldest queued debug message was retained: %q", joined) + } + if !strings.Contains(joined, "diagnostics queue overflow") { + t.Fatalf("overflow report missing: %q", joined) + } + if diagnostics.Dropped() == 0 { + t.Fatal("drop count was not observable") + } +} + +func TestAsyncLoggingNoncriticalRecordCannotEvictCriticalRecords(t *testing.T) { + writer := newControlledWriter() + writer.autoRelease = true + diagnostics := NewAsync(writer, AsyncOptions{Options: Options{Level: slog.LevelDebug}, Capacity: 2, StopTimeout: time.Second, CancelGrace: 10 * time.Millisecond}) + logger := diagnostics.Logger() + logger.Debug("active writer") + select { + case <-writer.started: + case <-time.After(time.Second): + t.Fatal("diagnostics writer did not start") + } + logger.Warn("warning 1") + logger.Error("error 2") + logger.Debug("discarded debug") + writer.releaseWrites(1) + + if err := diagnostics.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + joined := strings.Join(messageNames(t, writer.lines()), "\n") + for _, message := range []string{"warning 1", "error 2", "diagnostics queue overflow"} { + if !strings.Contains(joined, message) { + t.Fatalf("%q missing from diagnostics: %q", message, joined) + } + } + if strings.Contains(joined, "discarded debug") { + t.Fatalf("noncritical record displaced a critical record: %q", joined) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { + return 0, errors.New("write failed") +} + +func (failingWriter) Cancel() error { return nil } +func (failingWriter) Close() error { return nil } + +func TestAsyncStopReportsWriteFailure(t *testing.T) { + diagnostics := NewAsync(failingWriter{}, AsyncOptions{Capacity: 4, StopTimeout: time.Second, CancelGrace: 10 * time.Millisecond}) + diagnostics.Logger().Error("critical message") + + err := diagnostics.Stop(context.Background()) + if err == nil || !strings.Contains(err.Error(), "write diagnostics: write failed") { + t.Fatalf("Stop error = %v, want write failure", err) + } + if err := diagnostics.Stop(context.Background()); err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("repeated Stop error = %v, want preserved write failure", err) + } +} + +type timeoutWriter struct { + release chan struct{} +} + +func (writer *timeoutWriter) Write([]byte) (int, error) { + <-writer.release + return 0, nil +} + +func (writer *timeoutWriter) Cancel() error { return nil } +func (writer *timeoutWriter) Close() error { return nil } + +func TestAsyncStopTimeoutIsPreservedAndRepeatedStopJoins(t *testing.T) { + writer := &timeoutWriter{release: make(chan struct{})} + diagnostics := NewAsync(writer, AsyncOptions{Capacity: 4, StopTimeout: 40 * time.Millisecond, CancelGrace: 10 * time.Millisecond}) + diagnostics.Logger().Info("blocked message") + + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + firstError := diagnostics.Stop(ctx) + if !errors.Is(firstError, context.DeadlineExceeded) { + t.Fatalf("first Stop error = %v, want deadline exceeded", firstError) + } + + writer.release <- struct{}{} + secondError := diagnostics.Stop(context.Background()) + if !errors.Is(secondError, context.DeadlineExceeded) { + t.Fatalf("second Stop error = %v, want preserved deadline exceeded", secondError) + } +} + +func TestAsyncRepeatedStopAfterSuccess(t *testing.T) { + var output bytes.Buffer + diagnostics := NewAsync(NewPassthroughWriter(&output), AsyncOptions{Capacity: 4, StopTimeout: time.Second, CancelGrace: 10 * time.Millisecond}) + diagnostics.Logger().Info("message") + + if err := diagnostics.Stop(context.Background()); err != nil { + t.Fatalf("first Stop: %v", err) + } + if err := diagnostics.Stop(context.Background()); err != nil { + t.Fatalf("second Stop: %v", err) + } + if !strings.Contains(output.String(), "message") { + t.Fatalf("flushed output = %q", output.String()) + } +} + +func TestOpenFileIsAppendOnlyOwnerRestrictedAndCancellable(t *testing.T) { + path := filepath.Join(t.TempDir(), "diagnostics.log") + writer, err := OpenFile(path, nil) + if err != nil { + t.Fatalf("OpenFile: %v", err) + } + if _, err := writer.Write([]byte("first\n")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("file mode = %o, want 0600", info.Mode().Perm()) + } + writer, err = OpenFile(path, nil) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if _, err := writer.Write([]byte("second\n")); err != nil { + t.Fatalf("append: %v", err) + } + if err := writer.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "first\nsecond\n" { + t.Fatalf("file contents = %q, want append-only data", string(data)) + } +} + +var _ io.Writer = (*controlledWriter)(nil) diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..0d076f8 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,231 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package logging + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "runtime" + "strings" + "sync" + "time" +) + +const redactedValue = "[REDACTED]" + +type Options struct { + Level slog.Level + AddSource bool + SensitiveKeys []string +} + +type Handler struct { + writer io.Writer + writerLock *sync.Mutex + level slog.Level + addSource bool + sensitiveKeys map[string]struct{} + group string + attrs []slog.Attr +} + +func New(writer io.Writer, options Options) *slog.Logger { + return slog.New(newHandler(writer, options)) +} + +func newHandler(writer io.Writer, options Options) *Handler { + if writer == nil { + writer = io.Discard + } + level := options.Level + if level == 0 { + level = slog.LevelInfo + } + sensitiveKeys := map[string]struct{}{} + for _, key := range append(defaultSensitiveKeys(), options.SensitiveKeys...) { + sensitiveKeys[lowercaseKey(key)] = struct{}{} + } + return &Handler{ + writer: writer, + writerLock: &sync.Mutex{}, + level: level, + addSource: options.AddSource, + sensitiveKeys: sensitiveKeys, + } +} + +func (handler *Handler) Enabled(_ context.Context, level slog.Level) bool { + return level >= handler.level +} + +func (handler *Handler) Handle(_ context.Context, record slog.Record) error { + _, err := handler.write(handler.format(record)) + return err +} + +func (handler *Handler) format(record slog.Record) []byte { + var buffer bytes.Buffer + buffer.WriteByte('{') + appendField(&buffer, "message", record.Message) + appendField(&buffer, "level", record.Level.String()) + if handler.addSource && record.PC != 0 { + frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next() + appendField(&buffer, "source", fmt.Sprintf("%s:%d", frame.File, frame.Line)) + appendField(&buffer, "function", frame.Function) + } + + for _, attr := range handler.attrs { + handler.appendAttr(&buffer, attr, "") + } + record.Attrs(func(attr slog.Attr) bool { + handler.appendAttr(&buffer, attr, handler.group) + return true + }) + + appendField(&buffer, "time", record.Time.Format(time.RFC3339Nano)) + + buffer.WriteByte('}') + buffer.WriteByte('\n') + return buffer.Bytes() +} + +func (handler *Handler) write(data []byte) (int, error) { + handler.writerLock.Lock() + defer handler.writerLock.Unlock() + return handler.writer.Write(data) +} + +func (handler *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + clone := *handler + clone.attrs = append([]slog.Attr(nil), handler.attrs...) + prefix := "" + if handler.group != "" { + prefix = handler.group + "." + } + for _, attr := range attrs { + if prefix == "" { + clone.attrs = append(clone.attrs, attr) + continue + } + clone.attrs = append(clone.attrs, slog.Attr{Key: prefix + attr.Key, Value: attr.Value}) + } + return &clone +} + +func (handler *Handler) WithGroup(name string) slog.Handler { + if name == "" { + return handler + } + clone := *handler + if handler.group == "" { + clone.group = name + } else { + clone.group = handler.group + "." + name + } + return &clone +} + +func (handler *Handler) appendAttr(buffer *bytes.Buffer, attr slog.Attr, prefix string) { + attr.Value = attr.Value.Resolve() + if attr.Equal(slog.Attr{}) { + return + } + if attr.Value.Kind() == slog.KindGroup { + groupPrefix := prefix + if attr.Key != "" && prefix != "" { + groupPrefix = prefix + "." + attr.Key + } else if attr.Key != "" { + groupPrefix = attr.Key + } + for _, child := range attr.Value.Group() { + handler.appendAttr(buffer, child, groupPrefix) + } + return + } + + key := attr.Key + if prefix != "" { + key = prefix + "." + attr.Key + } + var value any + if attr.Value.Kind() == slog.KindString { + value = attr.Value.String() + } else { + value = attr.Value.Any() + } + if handler.isSensitive(key) { + value = redactedValue + } else if err, ok := value.(error); ok { + value = err.Error() + } + appendField(buffer, key, value) +} + +func (handler *Handler) isSensitive(key string) bool { + normalized := lowercaseKey(key) + if _, exists := handler.sensitiveKeys[normalized]; exists { + return true + } + lastDot := len(normalized) - 1 + for lastDot >= 0 && normalized[lastDot] != '.' { + lastDot-- + } + _, exists := handler.sensitiveKeys[normalized[lastDot+1:]] + return exists +} + +func appendField(buffer *bytes.Buffer, key string, value any) { + if buffer.Len() > 1 { + buffer.WriteByte(',') + } + encodedKey, err := json.Marshal(key) + if err != nil { + encodedKey = []byte(fmt.Sprintf("%q", key)) + } + encodedValue, err := json.Marshal(value) + if err != nil { + encodedValue = []byte(fmt.Sprintf("%q", fmt.Sprint(value))) + } + buffer.Write(encodedKey) + buffer.WriteByte(':') + buffer.Write(encodedValue) +} + +func defaultSensitiveKeys() []string { + return []string{ + "authorization", + "document_text", + "host", + "hostname", + "password", + "private_text", + "protected_text", + "secret", + "token", + "url", + "username", + } +} + +func lowercaseKey(key string) string { + return strings.ToLower(key) +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..b713e19 --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,158 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package logging + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "testing" +) + +func TestRedactsSensitiveFieldsAndGroups(t *testing.T) { + var output bytes.Buffer + logger := New(&output, Options{}) + + logger.Info( + "request accepted", + "password", "generic-secret", + "username", "Username", + "request", slog.GroupValue( + slog.String("token", "generic-token"), + slog.String("role", "input"), + ), + ) + + line := output.String() + if !json.Valid([]byte(line)) { + t.Fatalf("output is not valid JSON: %q", line) + } + if strings.Contains(line, "generic-secret") || strings.Contains(line, "generic-token") { + t.Fatalf("sensitive value was logged: %q", line) + } + if !strings.Contains(line, redactedValue) { + t.Fatalf("redaction marker missing: %q", line) + } + if !strings.Contains(line, `"request.role":"input"`) { + t.Fatalf("non-sensitive group value missing: %q", line) + } +} + +func TestJSONTimestampIsLastField(t *testing.T) { + var output bytes.Buffer + logger := New(&output, Options{}) + logger.Info("starting", "role", "screen-reader") + + line := output.String() + timeIndex := strings.LastIndex(line, `"time":`) + roleIndex := strings.LastIndex(line, `"role":`) + if timeIndex < 0 || roleIndex < 0 { + t.Fatalf("structured field missing: %q", line) + } + if timeIndex < roleIndex || !strings.HasPrefix(line[timeIndex:], `"time":"`) || + !strings.HasSuffix(line, "\"}\n") { + t.Fatalf("time is not the last field: %q", line) + } + if !strings.Contains(line, `"message":"starting"`) || !strings.Contains(line, `"role":"screen-reader"`) { + t.Fatalf("expected fields missing: %q", line) + } +} + +func TestRespectsLevelAndCustomSensitiveKey(t *testing.T) { + var output bytes.Buffer + logger := New(&output, Options{ + Level: slog.LevelWarn, + SensitiveKeys: []string{"api_key"}, + }) + + logger.Info("debug event", "api_key", "generic-key") + if output.Len() != 0 { + t.Fatalf("disabled info event was written: %q", output.String()) + } + logger.Warn("warning event", "api_key", "generic-key") + if !strings.Contains(output.String(), redactedValue) { + t.Fatalf("custom sensitive key was not redacted: %q", output.String()) + } +} + +func TestErrorsIncludeWrappedAndJoinedMessages(t *testing.T) { + var output bytes.Buffer + logger := New(&output, Options{}) + logger.Error("delivery failed", "error", errors.Join( + fmt.Errorf("send speech: %w", errors.New("connection closed")), + errors.New("cancellation failed"), + )) + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + if record["error"] != "send speech: connection closed\ncancellation failed" { + t.Fatalf("error = %v", record["error"]) + } +} + +func TestDebugLevelAndSource(t *testing.T) { + for _, enabled := range []bool{false, true} { + var output bytes.Buffer + level := slog.LevelInfo + if enabled { + level = slog.LevelDebug + } + logger := New(&output, Options{Level: level, AddSource: enabled}) + logger.Debug("event received") + if !enabled { + if output.Len() != 0 { + t.Fatal("debug logged without debug level") + } + continue + } + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + if !strings.Contains(fmt.Sprint(record["source"]), "logging_test.go:") || + !strings.Contains(fmt.Sprint(record["function"]), "TestDebugLevelAndSource") { + t.Fatalf("missing call site: %v", record) + } + } +} + +type diagnosticValue struct{} + +func (diagnosticValue) LogValue() slog.Value { + return slog.GroupValue(slog.String("token", "generic-token"), slog.String("role", "terminal")) +} + +func TestResolvedAndBoundGroups(t *testing.T) { + var output bytes.Buffer + logger := New(&output, Options{}).WithGroup("speech").With( + slog.Group("request", slog.String("role", "terminal")), + ) + logger.Info("event", "details", diagnosticValue{}) + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + if record["speech.request.role"] != "terminal" || record["speech.details.role"] != "terminal" || + record["speech.details.token"] != redactedValue { + t.Fatalf("incorrect grouped values: %v", record) + } +} diff --git a/internal/notification/mako.go b/internal/notification/mako.go new file mode 100644 index 0000000..9dab888 --- /dev/null +++ b/internal/notification/mako.go @@ -0,0 +1,395 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package notification + +import ( + "context" + "fmt" + "html" + "regexp" + "strings" + "sync" + + "github.com/godbus/dbus/v5" +) + +const ( + MakoSource = "mako" + makoBusName = "org.freedesktop.Notifications" + makoObjectPath = "/fr/emersion/Mako" + makoInterface = "fr.emersion.Mako" +) + +type MakoConnection interface { + Signal(chan<- *dbus.Signal) + RemoveSignal(subscription chan<- *dbus.Signal) + Object(dest string, path dbus.ObjectPath) dbus.BusObject + AddMatchSignal(match ...dbus.MatchOption) error + RemoveMatchSignal(match ...dbus.MatchOption) error + Close() error +} + +type MakoAnnouncer interface { + Announce(entry Entry) +} + +type MakoMonitor struct { + connection MakoConnection + presenter *Presenter + announcer MakoAnnouncer + + mutex sync.Mutex + generation int + available bool + known map[int]struct{} + running bool + stop chan struct{} + stopped chan struct{} + subscription <-chan *dbus.Signal +} + +type MakoController interface { + IsCurrent(entry Entry) bool + Dismiss(notificationID int) bool + InvokeAction(notificationID int, actionKey string) bool +} + +func NewMakoMonitor(connection MakoConnection, presenter *Presenter, announcer MakoAnnouncer) *MakoMonitor { + return &MakoMonitor{ + connection: connection, + presenter: presenter, + announcer: announcer, + known: make(map[int]struct{}), + stop: make(chan struct{}), + stopped: make(chan struct{}), + } +} + +func (monitor *MakoMonitor) Start(ctx context.Context) error { + monitor.mutex.Lock() + if monitor.running { + monitor.mutex.Unlock() + return nil + } + monitor.running = true + monitor.stop = make(chan struct{}) + monitor.stopped = make(chan struct{}) + monitor.mutex.Unlock() + + go monitor.watch(ctx) + return nil +} + +func (monitor *MakoMonitor) Stop(ctx context.Context) error { + monitor.mutex.Lock() + if !monitor.running { + monitor.mutex.Unlock() + return nil + } + monitor.running = false + close(monitor.stop) + monitor.mutex.Unlock() + + select { + case <-monitor.stopped: + case <-ctx.Done(): + return ctx.Err() + } + + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + monitor.unsubscribe() + monitor.presenter.MarkSourceUnavailable(MakoSource) + clear(monitor.known) + monitor.available = false + return monitor.connection.Close() +} + +func (monitor *MakoMonitor) Refresh(announceNew bool) bool { + monitor.mutex.Lock() + available := monitor.available + monitor.mutex.Unlock() + if !available { + return false + } + + notifications, err := monitor.list() + if err != nil { + monitor.markUnavailable() + return false + } + monitor.sync(notifications, announceNew) + return true +} + +func (monitor *MakoMonitor) activate(announceNew bool) bool { + notifications, err := monitor.list() + if err != nil { + monitor.markUnavailable() + return false + } + monitor.mutex.Lock() + monitor.generation++ + monitor.available = true + clear(monitor.known) + monitor.mutex.Unlock() + monitor.sync(notifications, announceNew) + return true +} + +func (monitor *MakoMonitor) Dismiss(notificationID int) bool { + options := map[string]dbus.Variant{ + "id": dbus.MakeVariant(uint32(notificationID)), + "group": dbus.MakeVariant(false), + "history": dbus.MakeVariant(true), + "all": dbus.MakeVariant(false), + } + if err := monitor.object().Call("fr.emersion.Mako.DismissNotifications", 0, options).Err; err != nil { + return false + } + monitor.Refresh(false) + return true +} + +func (monitor *MakoMonitor) InvokeAction(notificationID int, actionKey string) bool { + if err := monitor.object().Call("fr.emersion.Mako.InvokeAction", 0, uint32(notificationID), actionKey).Err; err != nil { + return false + } + monitor.Refresh(false) + return true +} + +func (monitor *MakoMonitor) IsCurrent(entry Entry) bool { + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + return monitor.available && entry.Source == MakoSource && + entry.SourceGeneration == monitor.generation && entry.Live +} + +func (monitor *MakoMonitor) Generation() int { + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + return monitor.generation +} + +func (monitor *MakoMonitor) watch(ctx context.Context) { + defer close(monitor.stopped) + signals := make(chan *dbus.Signal, 16) + monitor.subscription = signals + monitor.connection.Signal(signals) + defer monitor.connection.RemoveSignal(signals) + + if err := monitor.connection.AddMatchSignal( + dbus.WithMatchObjectPath(makoObjectPath), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + ); err != nil { + monitor.markUnavailable() + return + } + if err := monitor.connection.AddMatchSignal( + dbus.WithMatchSender("org.freedesktop.DBus"), + dbus.WithMatchObjectPath("/org/freedesktop/DBus"), + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + ); err != nil { + monitor.markUnavailable() + return + } + monitor.activate(false) + + for { + select { + case <-ctx.Done(): + return + case <-monitor.stop: + return + case signal := <-signals: + monitor.handleSignal(signal) + } + } +} + +func (monitor *MakoMonitor) handleSignal(signal *dbus.Signal) { + if signal == nil { + return + } + switch signal.Name { + case "org.freedesktop.DBus.Properties.PropertiesChanged": + if signal.Path == makoObjectPath { + monitor.Refresh(true) + } + case "org.freedesktop.DBus.NameOwnerChanged": + if signal.Path != "/org/freedesktop/DBus" || len(signal.Body) < 3 { + return + } + name, _ := signal.Body[0].(string) + newOwner, _ := signal.Body[2].(string) + if name != makoBusName { + return + } + if newOwner == "" { + monitor.markUnavailable() + } else { + monitor.activate(false) + } + } +} + +func (monitor *MakoMonitor) sync(notifications []map[string]dbus.Variant, announceNew bool) { + parsed := make([]LiveMetadata, 0, len(notifications)) + ids := make([]int, 0, len(notifications)) + live := make(map[int]LiveMetadata) + for _, notification := range notifications { + metadata, id := parseMako(notification) + if id == nil { + continue + } + parsed = append(parsed, metadata) + ids = append(ids, *id) + live[*id] = metadata + } + monitor.presenter.SyncLive(MakoSource, monitor.Generation(), live) + if announceNew { + for index, id := range ids { + if monitor.knownID(id) { + continue + } + notificationID := id + monitor.announcer.Announce(Entry{ + Message: parsed[index].Message, + Source: MakoSource, + SourceGeneration: monitor.Generation(), + NotificationID: ¬ificationID, + Live: true, + Actions: parsed[index].Actions, + AppName: parsed[index].AppName, + Summary: parsed[index].Summary, + Body: parsed[index].Body, + Urgency: parsed[index].Urgency, + DesktopEntry: parsed[index].DesktopEntry, + }) + } + } + monitor.replaceKnown(live) +} + +func (monitor *MakoMonitor) list() ([]map[string]dbus.Variant, error) { + var reply []map[string]dbus.Variant + object := monitor.object() + if err := object.Call(makoInterface+".ListNotifications", 0).Store(&reply); err != nil { + return nil, fmt.Errorf("list mako notifications: %w", err) + } + return reply, nil +} + +func (monitor *MakoMonitor) object() dbus.BusObject { + return monitor.connection.Object(makoBusName, makoObjectPath) +} + +func (monitor *MakoMonitor) knownID(id int) bool { + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + _, known := monitor.known[id] + return known +} + +func (monitor *MakoMonitor) replaceKnown(live map[int]LiveMetadata) { + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + monitor.known = make(map[int]struct{}, len(live)) + for id := range live { + monitor.known[id] = struct{}{} + } +} + +func (monitor *MakoMonitor) markUnavailable() { + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + monitor.available = false + clear(monitor.known) + monitor.presenter.MarkSourceUnavailable(MakoSource) +} + +func (monitor *MakoMonitor) unsubscribe() { + if monitor.subscription != nil { + _ = monitor.connection.RemoveMatchSignal( + dbus.WithMatchObjectPath(makoObjectPath), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + ) + _ = monitor.connection.RemoveMatchSignal( + dbus.WithMatchSender("org.freedesktop.DBus"), + dbus.WithMatchObjectPath("/org/freedesktop/DBus"), + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + ) + } +} + +var ( + makoTagPattern = regexp.MustCompile(`<[^>]+>`) + makoWhitespacePattern = regexp.MustCompile(`\s+`) +) + +func parseMako(notification map[string]dbus.Variant) (LiveMetadata, *int) { + id, idValid := notification["id"].Value().(uint32) + if !idValid { + return LiveMetadata{}, nil + } + metadata := LiveMetadata{ + AppName: normalizeMakoText(notification["app-name"]), + Summary: normalizeMakoText(notification["summary"]), + Body: normalizeMakoText(notification["body"]), + DesktopEntry: normalizeMakoText(notification["desktop-entry"]), + } + if value, valid := notification["urgency"].Value().(int32); valid { + metadata.Urgency = int(value) + } else { + metadata.Urgency = -1 + } + if actions, valid := notification["actions"].Value().(map[string]dbus.Variant); valid { + metadata.Actions = make(map[string]string, len(actions)) + for key, action := range actions { + value := normalizeMakoText(action) + if value == "" { + value = key + } + metadata.Actions[key] = value + } + } + notificationID := int(id) + metadata.Message = buildMakoMessage(metadata) + return metadata, ¬ificationID +} + +func normalizeMakoText(variant dbus.Variant) string { + text, valid := variant.Value().(string) + if !valid { + return "" + } + text = html.UnescapeString(text) + text = makoTagPattern.ReplaceAllString(text, " ") + text = makoWhitespacePattern.ReplaceAllString(text, " ") + return strings.TrimSpace(text) +} + +func buildMakoMessage(metadata LiveMetadata) string { + parts := []string{MessageNotification} + if metadata.Summary != "" { + parts = append(parts, metadata.Summary) + } + if metadata.Body != "" && metadata.Body != metadata.Summary { + parts = append(parts, metadata.Body) + } + if len(parts) == 1 { + fallback := metadata.AppName + if fallback == "" { + fallback = metadata.DesktopEntry + } + if fallback != "" { + parts = append(parts, fallback) + } + } + return strings.Join(parts, " ") +} diff --git a/internal/notification/mako_test.go b/internal/notification/mako_test.go new file mode 100644 index 0000000..644e25c --- /dev/null +++ b/internal/notification/mako_test.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package notification + +import ( + "context" + "errors" + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestMakoSyncAnnouncesOnlyNewNotifications(t *testing.T) { + presenter := NewPresenter() + announcer := &fakeAnnouncer{} + monitor := NewMakoMonitor(nil, presenter, announcer) + monitor.available = true + + notifications := []map[string]dbus.Variant{makoNotification(7)} + monitor.sync(notifications, true) + if len(announcer.entries) != 1 { + t.Fatalf("announced %d notifications, want 1", len(announcer.entries)) + } + if announcer.entries[0].Message != "Notification Mail You have one message" { + t.Fatalf("message = %q", announcer.entries[0].Message) + } + + monitor.sync(notifications, true) + if len(announcer.entries) != 1 { + t.Fatalf("announced %d notifications after refresh, want 1", len(announcer.entries)) + } +} + +func TestMakoParseNormalizesMetadata(t *testing.T) { + metadata, id := parseMako(makoNotification(9)) + if id == nil || *id != 9 { + t.Fatalf("id = %v, want 9", id) + } + if metadata.AppName != "Application" || metadata.Summary != "Mail" || + metadata.Body != "You have one message" || metadata.DesktopEntry != "application" || + metadata.Urgency != 2 || metadata.Actions["default"] != "View" { + t.Fatalf("metadata = %+v", metadata) + } +} + +func TestMakoStopMarksLiveHistoryUnavailable(t *testing.T) { + presenter := NewPresenter() + notificationID := 12 + presenter.Save("Notification Mail", SaveOptions{ + Source: MakoSource, NotificationID: ¬ificationID, Live: true, + }) + monitor := NewMakoMonitor(&fakeMakoConnection{}, presenter, &fakeAnnouncer{}) + monitor.available = true + monitor.running = true + monitor.stop = make(chan struct{}) + monitor.stopped = make(chan struct{}) + close(monitor.stopped) + if err := monitor.Stop(context.Background()); err != nil { + t.Fatalf("stop mako: %v", err) + } + entries := presenter.Entries() + if len(entries) != 1 || entries[0].Live { + t.Fatalf("entries = %+v, want one unavailable entry", entries) + } +} + +func TestMakoOwnerLossMarksLiveHistoryUnavailable(t *testing.T) { + presenter := NewPresenter() + notificationID := 12 + presenter.Save("Notification Mail", SaveOptions{ + Source: MakoSource, NotificationID: ¬ificationID, Live: true, + }) + monitor := NewMakoMonitor(&fakeMakoConnection{}, presenter, &fakeAnnouncer{}) + monitor.available = true + monitor.handleSignal(&dbus.Signal{ + Name: "org.freedesktop.DBus.NameOwnerChanged", + Path: "/org/freedesktop/DBus", + Body: []interface{}{makoBusName, ":1.10", ""}, + }) + + entries := presenter.Entries() + if monitor.available || len(entries) != 1 || entries[0].Live { + t.Fatalf("available = %v, entries = %+v", monitor.available, entries) + } +} + +func TestMakoUnsupportedDaemonMarksHistoryUnavailable(t *testing.T) { + presenter := NewPresenter() + notificationID := 12 + presenter.Save("Notification Mail", SaveOptions{ + Source: MakoSource, NotificationID: ¬ificationID, Live: true, + }) + monitor := NewMakoMonitor(&fakeMakoConnection{ + object: &fakeMakoObject{err: errors.New("object does not exist")}, + }, presenter, &fakeAnnouncer{}) + monitor.available = true + + if monitor.Refresh(false) { + t.Fatal("unsupported notification daemon reported as available") + } + entries := presenter.Entries() + if len(entries) != 1 || entries[0].Live { + t.Fatalf("entries = %+v, want one unavailable entry", entries) + } + if len(presenter.ActionsForEntry(entries[0])) != 0 { + t.Fatal("unsupported notification daemon retained live actions") + } +} + +func TestPresenterRoutesLiveMakoActions(t *testing.T) { + presenter := NewPresenter() + controller := &fakeMakoController{} + presenter.SetMakoController(controller) + notificationID := 6 + entry := presenter.Save("Notification Mail New message", SaveOptions{ + Source: MakoSource, SourceGeneration: 1, + NotificationID: ¬ificationID, Live: true, + Actions: map[string]string{"default": "View"}, + }) + + if !presenter.CanControlEntry(entry) { + t.Fatal("live mako entry was not controllable") + } + if actions := presenter.ActionsForEntry(entry); actions["default"] != "View" { + t.Fatalf("actions = %q, want View", actions["default"]) + } + + messages, dismissed := presenter.DismissEntry(entry) + if !dismissed || messages[0] != MessageDismissed { + t.Fatalf("dismiss = %v %q", dismissed, messages) + } + if len(presenter.Entries()) != 0 || controller.dismissed[0] != 6 { + t.Fatalf("entries = %+v dismissed = %v", presenter.Entries(), controller.dismissed) + } + + actionMessages, invoked := presenter.InvokeActionEntry(entry, "default") + if !invoked || actionMessages[0] != MessageActionInvoked || controller.invoked[0] != (makoAction{6, "default"}) { + t.Fatalf("invoke = %v %q", invoked, actionMessages) + } +} + +func makoNotification(id uint32) map[string]dbus.Variant { + return map[string]dbus.Variant{ + "id": dbus.MakeVariant(id), + "app-name": dbus.MakeVariant("Application"), + "summary": dbus.MakeVariant("Mail"), + "body": dbus.MakeVariant("You have one message"), + "desktop-entry": dbus.MakeVariant("application"), + "urgency": dbus.MakeVariant(int32(2)), + "actions": dbus.MakeVariant(map[string]dbus.Variant{"default": dbus.MakeVariant("View")}), + } +} + +type fakeAnnouncer struct{ entries []Entry } + +func (announcer *fakeAnnouncer) Announce(entry Entry) { + announcer.entries = append(announcer.entries, entry) +} + +type fakeMakoConnection struct { + object *fakeMakoObject +} + +func (connection *fakeMakoConnection) Signal(chan<- *dbus.Signal) {} + +func (connection *fakeMakoConnection) RemoveSignal(chan<- *dbus.Signal) {} +func (connection *fakeMakoConnection) Object(string, dbus.ObjectPath) dbus.BusObject { + return connection.object +} +func (connection *fakeMakoConnection) AddMatchSignal(...dbus.MatchOption) error { return nil } +func (connection *fakeMakoConnection) RemoveMatchSignal(...dbus.MatchOption) error { return nil } +func (connection *fakeMakoConnection) Close() error { return nil } + +type fakeMakoObject struct { + err error +} + +func (object *fakeMakoObject) Call(string, dbus.Flags, ...interface{}) *dbus.Call { + return &dbus.Call{Err: object.err} +} +func (object *fakeMakoObject) CallWithContext(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call { + return &dbus.Call{Err: object.err} +} +func (object *fakeMakoObject) Go(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call { + return &dbus.Call{Err: object.err} +} +func (object *fakeMakoObject) GoWithContext(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call { + return &dbus.Call{Err: object.err} +} +func (object *fakeMakoObject) AddMatchSignal(string, string, ...dbus.MatchOption) *dbus.Call { + return &dbus.Call{Err: object.err} +} +func (object *fakeMakoObject) RemoveMatchSignal(string, string, ...dbus.MatchOption) *dbus.Call { + return &dbus.Call{Err: object.err} +} +func (object *fakeMakoObject) GetProperty(string) (dbus.Variant, error) { + return dbus.Variant{}, object.err +} +func (object *fakeMakoObject) StoreProperty(string, interface{}) error { + return object.err +} +func (object *fakeMakoObject) SetProperty(string, interface{}) error { + return object.err +} +func (object *fakeMakoObject) Destination() string { return makoBusName } +func (object *fakeMakoObject) Path() dbus.ObjectPath { return makoObjectPath } + +type makoAction struct { + id int + key string +} + +type fakeMakoController struct { + dismissed []int + invoked []makoAction +} + +func (controller *fakeMakoController) IsCurrent(Entry) bool { return true } +func (controller *fakeMakoController) Dismiss(id int) bool { + controller.dismissed = append(controller.dismissed, id) + return true +} +func (controller *fakeMakoController) InvokeAction(id int, key string) bool { + controller.invoked = append(controller.invoked, makoAction{id, key}) + return true +} diff --git a/internal/notification/presenter.go b/internal/notification/presenter.go new file mode 100644 index 0000000..9d1fb92 --- /dev/null +++ b/internal/notification/presenter.go @@ -0,0 +1,442 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package notification + +import ( + "fmt" + "math" + "sync" + "time" +) + +var noMessages []string + +const historySize = 55 + +const ( + MessageNotification = "Notification" + MessageListBottom = "Bottom" + MessageListTop = "Top" + MessageNoMessages = "No notification messages" + MessageUnavailable = "Notification is no longer available" + MessageNoActions = "No notification actions available" + MessageDismissed = "Notification dismissed" + MessageActionInvoked = "Notification action invoked" + SourceGeneric = "generic" + ListTitle = "Notifications" + ListColumnMessages = "Notifications" + ListColumnReceived = "Received" + ListDismissButton = "Dismiss Notification" + ListActionsButton = "Notification Actions" + ListActionsTitle = "Notification Actions" + ListInvokeActionButton = "Invoke Action" + ListClearButton = "Clear" + ListCopyButton = "Copy" + ListCloseButton = "Close" +) + +type Entry struct { + ID string + Message string + Timestamp time.Time + Source string + SourceGeneration int + NotificationID *int + Live bool + Actions map[string]string + AppName string + Summary string + Body string + Urgency int + DesktopEntry string +} + +type SaveOptions struct { + Source string + SourceGeneration int + NotificationID *int + Live bool + Actions map[string]string + AppName string + Summary string + Body string + Urgency int + DesktopEntry string +} + +type LiveMetadata struct { + Message string + Actions map[string]string + AppName string + Summary string + Body string + Urgency int + DesktopEntry string +} + +type Presenter struct { + mutex sync.Mutex + now func() time.Time + mako MakoController + + entries []Entry + currentIndex int +} + +func NewPresenter() *Presenter { + return &Presenter{now: time.Now, currentIndex: -1} +} + +func NewPresenterAt(now func() time.Time) *Presenter { + return &Presenter{now: now, currentIndex: -1} +} + +func (presenter *Presenter) SetMakoController(controller MakoController) { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + presenter.mako = controller +} + +func (presenter *Presenter) Save(message string, options ...SaveOptions) Entry { + option := SaveOptions{} + if len(options) != 0 { + option = options[0] + } + if option.Source == "" { + option.Source = SourceGeneric + } + + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + if excess := len(presenter.entries) - historySize + 1; excess > 0 { + presenter.entries = presenter.entries[excess:] + } + entry := Entry{ + Message: message, + Timestamp: presenter.now(), + Source: option.Source, + SourceGeneration: option.SourceGeneration, + NotificationID: option.NotificationID, + Live: option.Live, + Actions: cloneActions(option.Actions), + AppName: option.AppName, + Summary: option.Summary, + Body: option.Body, + Urgency: option.Urgency, + DesktopEntry: option.DesktopEntry, + } + presenter.entries = append(presenter.entries, entry) + return entry +} + +func (presenter *Presenter) Clear() { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + presenter.entries = nil + presenter.currentIndex = -1 +} + +func (presenter *Presenter) Entries() []Entry { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + return append([]Entry(nil), presenter.entries...) +} + +func (presenter *Presenter) EntriesForList() []Entry { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + return presenter.entriesForList() +} + +func (presenter *Presenter) ListTitle() string { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + return presenter.listTitle() +} + +func (presenter *Presenter) EntryText(entry Entry) string { + presenter.mutex.Lock() + now := presenter.now() + presenter.mutex.Unlock() + return entryText(entry, now) +} + +func (presenter *Presenter) ReceivedTime(timestamp time.Time) string { + presenter.mutex.Lock() + now := presenter.now() + presenter.mutex.Unlock() + return relativeTime(timestamp, now) +} + +func (presenter *Presenter) RefreshLive() bool { + presenter.mutex.Lock() + controller := presenter.mako + presenter.mutex.Unlock() + refresher, ok := controller.(interface{ Refresh(bool) bool }) + if !ok { + return false + } + return refresher.Refresh(false) +} + +func (presenter *Presenter) Remove(entry Entry) bool { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + for index, candidate := range presenter.entries { + if entriesEqual(candidate, entry) { + presenter.entries = append(presenter.entries[:index], presenter.entries[index+1:]...) + presenter.removeIndex(index) + return true + } + } + return false +} + +func (presenter *Presenter) SyncLive(source string, generation int, live map[int]LiveMetadata) { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + for index := range presenter.entries { + entry := &presenter.entries[index] + if entry.Source != source || entry.SourceGeneration != generation { + continue + } + if entry.NotificationID == nil { + continue + } + if metadata, exists := live[*entry.NotificationID]; exists { + entry.Live = true + if metadata.Message != "" { + entry.Message = metadata.Message + } + entry.Actions = cloneActions(metadata.Actions) + entry.AppName = metadata.AppName + entry.Summary = metadata.Summary + entry.Body = metadata.Body + entry.Urgency = metadata.Urgency + entry.DesktopEntry = metadata.DesktopEntry + } else if entry.Live { + entry.Live = false + } + } +} + +func (presenter *Presenter) MarkSourceUnavailable(source string) { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + for index := range presenter.entries { + if presenter.entries[index].Source == source && presenter.entries[index].Live { + presenter.entries[index].Live = false + } + } +} + +func (presenter *Presenter) CanControlEntry(entry Entry) bool { + presenter.mutex.Lock() + controller := presenter.mako + presenter.mutex.Unlock() + return entry.Live && entry.NotificationID != nil && controller != nil && controller.IsCurrent(entry) +} + +func (presenter *Presenter) ActionsForEntry(entry Entry) map[string]string { + if !presenter.CanControlEntry(entry) { + return map[string]string{} + } + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + return cloneActions(entry.Actions) +} + +func (presenter *Presenter) DismissEntry(entry Entry) ([]string, bool) { + if !presenter.CanControlEntry(entry) { + return []string{MessageUnavailable}, false + } + presenter.mutex.Lock() + controller := presenter.mako + presenter.mutex.Unlock() + if controller == nil || !controller.Dismiss(*entry.NotificationID) { + return []string{MessageUnavailable}, false + } + entry.Live = false + presenter.Remove(entry) + return []string{MessageDismissed}, true +} + +func (presenter *Presenter) InvokeActionEntry(entry Entry, actionKey string) ([]string, bool) { + if !presenter.CanControlEntry(entry) { + return []string{MessageUnavailable}, false + } + presenter.mutex.Lock() + controller := presenter.mako + presenter.mutex.Unlock() + if controller == nil || !controller.InvokeAction(*entry.NotificationID, actionKey) { + return []string{MessageUnavailable}, false + } + return []string{MessageActionInvoked}, true +} + +func (presenter *Presenter) PresentLast() []string { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + if len(presenter.entries) == 0 { + return []string{MessageNoMessages} + } + presenter.currentIndex = -1 + return []string{entryText(presenter.entries[len(presenter.entries)-1], presenter.now())} +} + +func (presenter *Presenter) PresentPrevious() []string { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + if len(presenter.entries) == 0 { + return []string{MessageNoMessages} + } + messages := noMessages + next := presenter.currentIndex - 1 + if next < -len(presenter.entries) { + messages = append(messages, MessageListTop) + } else { + presenter.currentIndex = next + } + current := len(presenter.entries) + presenter.currentIndex + return append(messages, entryText(presenter.entries[current], presenter.now())) +} + +func (presenter *Presenter) PresentNext() []string { + presenter.mutex.Lock() + defer presenter.mutex.Unlock() + if len(presenter.entries) == 0 { + return []string{MessageNoMessages} + } + messages := noMessages + if presenter.currentIndex == -1 { + messages = append(messages, MessageListBottom) + } else { + next := presenter.currentIndex + 1 + if next > -1 { + messages = append(messages, MessageListBottom) + } else { + presenter.currentIndex = next + } + } + current := len(presenter.entries) + presenter.currentIndex + return append(messages, entryText(presenter.entries[current], presenter.now())) +} + +func (presenter *Presenter) removeIndex(index int) { + if len(presenter.entries) == 0 { + presenter.currentIndex = -1 + return + } + if presenter.currentIndex == -1 { + return + } + // Navigation uses negative offsets from the newest entry; removal receives + // a forward slice index. Preserve the selected entry when it survives, or + // select its successor when removed, without retaining an out-of-range offset. + current := len(presenter.entries) + 1 + presenter.currentIndex + if index < current { + current-- + } + current = min(current, len(presenter.entries)-1) + presenter.currentIndex = current - len(presenter.entries) +} + +func (presenter *Presenter) entriesForList() []Entry { + entries := append([]Entry(nil), presenter.entries...) + for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 { + entries[left], entries[right] = entries[right], entries[left] + } + return entries +} + +func (presenter *Presenter) listTitle() string { + return plural(len(presenter.entries), "%d notification", "%d notifications") +} + +func entryText(entry Entry, now time.Time) string { + return fmt.Sprintf("%s %s", entry.Message, relativeTime(entry.Timestamp, now)) +} + +func relativeTime(timestamp, now time.Time) string { + elapsed := now.Sub(timestamp) + switch { + case elapsed < time.Minute: + count := int(elapsed.Seconds()) + return plural(count, "%d second ago", "%d seconds ago") + case elapsed < time.Hour: + count := roundLikePython(elapsed.Minutes()) + return plural(count, "%d minute ago", "%d minutes ago") + case elapsed < 24*time.Hour: + count := roundLikePython(elapsed.Hours()) + return plural(count, "%d hour ago", "%d hours ago") + default: + count := roundLikePython(elapsed.Hours() / 24) + return plural(count, "%d day ago", "%d days ago") + } +} + +func plural(count int, singular, pluralFormat string) string { + if count == 1 { + return fmt.Sprintf(singular, count) + } + return fmt.Sprintf(pluralFormat, count) +} + +func roundLikePython(value float64) int { + lower := math.Floor(value) + fraction := value - lower + switch { + case fraction > 0.5: + return int(lower) + 1 + case fraction == 0.5 && int(lower)%2 != 0: + return int(lower) + 1 + default: + return int(lower) + } +} + +func cloneActions(actions map[string]string) map[string]string { + if len(actions) == 0 { + return map[string]string{} + } + clone := make(map[string]string, len(actions)) + for key, value := range actions { + clone[key] = value + } + return clone +} + +func entriesEqual(left, right Entry) bool { + return left.Message == right.Message && + left.Timestamp.Equal(right.Timestamp) && + left.Source == right.Source && + left.SourceGeneration == right.SourceGeneration && + notificationIDsEqual(left.NotificationID, right.NotificationID) && + actionsEqual(left.Actions, right.Actions) && + left.AppName == right.AppName && + left.Summary == right.Summary && + left.Body == right.Body && + left.Urgency == right.Urgency && + left.DesktopEntry == right.DesktopEntry +} + +func notificationIDsEqual(left, right *int) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func actionsEqual(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} diff --git a/internal/notification/presenter_test.go b/internal/notification/presenter_test.go new file mode 100644 index 0000000..c516831 --- /dev/null +++ b/internal/notification/presenter_test.go @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package notification + +import ( + "testing" + "time" +) + +func TestPresenterSavesHistoryAndEnforcesMaximumSize(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + presenter := NewPresenterAt(func() time.Time { return now }) + for number := 0; number < historySize+2; number++ { + presenter.Save(time.Unix(int64(number), 0).Format("2006")) + now = now.Add(time.Second) + } + entries := presenter.Entries() + if len(entries) != historySize { + t.Fatalf("history length = %d, want %d", len(entries), historySize) + } + if entries[0].Source != SourceGeneric { + t.Fatalf("first source = %q, want %q", entries[0].Source, SourceGeneric) + } +} + +func TestPresenterNavigationMatchesReferenceBoundaries(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + presenter := NewPresenterAt(func() time.Time { return now }) + presenter.Save("first") + presenter.Save("second") + + assertMessages := func(t *testing.T, got []string, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("message count = %d, want %d: %q", len(got), len(want), got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("message %d = %q, want %q", index, got[index], want[index]) + } + } + } + t.Run("last", func(t *testing.T) { + assertMessages(t, presenter.PresentLast(), "second 0 seconds ago") + }) + t.Run("previous then top", func(t *testing.T) { + assertMessages(t, presenter.PresentPrevious(), "first 0 seconds ago") + assertMessages(t, presenter.PresentPrevious(), MessageListTop, "first 0 seconds ago") + }) + t.Run("next then bottom", func(t *testing.T) { + assertMessages(t, presenter.PresentNext(), "second 0 seconds ago") + assertMessages(t, presenter.PresentNext(), MessageListBottom, "second 0 seconds ago") + assertMessages(t, presenter.PresentNext(), MessageListBottom, "second 0 seconds ago") + }) + t.Run("empty", func(t *testing.T) { + empty := NewPresenterAt(func() time.Time { return now }) + assertMessages(t, empty.PresentLast(), MessageNoMessages) + assertMessages(t, empty.PresentPrevious(), MessageNoMessages) + assertMessages(t, empty.PresentNext(), MessageNoMessages) + }) +} + +func TestPresenterStartsAtLatestNotification(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + presenter := NewPresenterAt(func() time.Time { return now }) + presenter.Save("first") + presenter.Save("second") + + assertMessages(t, presenter.PresentPrevious(), "first 0 seconds ago") +} + +func TestPresenterRemovalKeepsHistoryNavigationValid(t *testing.T) { + for _, removeAt := range []int{0, 1, 2} { + t.Run(time.Duration(removeAt).String(), func(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + presenter := NewPresenterAt(func() time.Time { return now }) + entries := []Entry{presenter.Save("first"), presenter.Save("second"), presenter.Save("third")} + presenter.PresentPrevious() + presenter.PresentPrevious() + if !presenter.Remove(entries[removeAt]) { + t.Fatal("notification was not removed") + } + want := "first 0 seconds ago" + if removeAt == 0 { + want = "second 0 seconds ago" + } + assertMessages(t, presenter.PresentPrevious(), MessageListTop, want) + presenter.PresentNext() + }) + } +} + +func assertMessages(t *testing.T, got []string, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("message count = %d, want %d: %q", len(got), len(want), got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("message %d = %q, want %q", index, got[index], want[index]) + } + } +} + +func TestRelativeTimeUsesReferenceRounding(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + for _, test := range []struct { + elapsed time.Duration + want string + }{ + {90 * time.Second, "2 minutes ago"}, + {150 * time.Second, "2 minutes ago"}, + {89 * time.Second, "1 minute ago"}, + {90 * time.Minute, "2 hours ago"}, + {90 * time.Hour, "4 days ago"}, + } { + if got := relativeTime(now.Add(-test.elapsed), now); got != test.want { + t.Fatalf("relativeTime(%s) = %q, want %q", test.elapsed, got, test.want) + } + } +} diff --git a/internal/notificationui/native.c b/internal/notificationui/native.c new file mode 100644 index 0000000..e2402de --- /dev/null +++ b/internal/notificationui/native.c @@ -0,0 +1,263 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native.h" + +#include +#include + +enum { ACTION_NONE, ACTION_COPY, ACTION_DISMISS, ACTION_CLEAR, ACTION_CLOSE, + ACTION_SELECTION, ACTION_INVOKE }; + +static GtkWidget *dialog; +static GtkListStore *model; +static GtkTreeSelection *selection; +static GtkWidget *dismiss_button; +static GtkWidget *actions_box; +static GtkWidget *actions_status; +static int action_code; +static int suppress_selection; +static char *selected_action_key; +static char *selected_id_cache; + +static void set_action(int code) { + if (!action_code) action_code = code; +} + +static void response(GtkDialog *widget, int response_id, gpointer data) { + (void)widget; + (void)data; + if (response_id == ACTION_COPY) set_action(ACTION_COPY); + else if (response_id == ACTION_DISMISS) set_action(ACTION_DISMISS); + else if (response_id == ACTION_CLEAR) set_action(ACTION_CLEAR); + else if (response_id == ACTION_CLOSE) set_action(ACTION_CLOSE); +} + +static void selection_changed(GtkTreeSelection *widget, gpointer data) { + (void)widget; + (void)data; + if (!suppress_selection) set_action(ACTION_SELECTION); +} + +static void action_clicked(GtkButton *button, gpointer data) { + (void)data; + const char *key = g_object_get_data(G_OBJECT(button), "cthulhu-action-key"); + g_free(selected_action_key); + selected_action_key = g_strdup(key ? key : ""); + set_action(ACTION_INVOKE); +} + +static void clear_action_buttons(void) { + if (!actions_box) return; + GList *children = gtk_container_get_children(GTK_CONTAINER(actions_box)); + for (GList *item = children; item; item = item->next) gtk_widget_destroy(GTK_WIDGET(item->data)); + g_list_free(children); +} + +static void set_actions(char **keys, char **labels, int count) { + clear_action_buttons(); + for (int index = 0; index < count; index++) { + const char *label = labels && labels[index] && labels[index][0] ? labels[index] : keys[index]; + GtkWidget *button = gtk_button_new_with_label(label ? label : ""); + char *key = g_strdup(keys && keys[index] ? keys[index] : ""); + g_object_set_data_full(G_OBJECT(button), "cthulhu-action-key", key, g_free); + g_signal_connect(button, "clicked", G_CALLBACK(action_clicked), NULL); + gtk_box_pack_start(GTK_BOX(actions_box), button, FALSE, FALSE, 0); + } + gtk_widget_show_all(actions_box); +} + +int notification_list_init(const char *title, const char *message_header, + const char *received_header) { + if (!gtk_init_check(NULL, NULL)) return 0; + g_set_application_name("Cthulhu Notifications"); + dialog = gtk_dialog_new(); + gtk_window_set_title(GTK_WINDOW(dialog), title ? title : "Notifications"); + gtk_window_set_default_size(GTK_WINDOW(dialog), 600, 400); + gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); + gtk_dialog_add_button(GTK_DIALOG(dialog), "Copy", ACTION_COPY); + dismiss_button = gtk_dialog_add_button(GTK_DIALOG(dialog), "Dismiss Notification", ACTION_DISMISS); + gtk_dialog_add_button(GTK_DIALOG(dialog), "Clear", ACTION_CLEAR); + gtk_dialog_add_button(GTK_DIALOG(dialog), "Close", ACTION_CLOSE); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), ACTION_CLOSE); + g_signal_connect(dialog, "response", G_CALLBACK(response), NULL); + + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 12); + gtk_container_add(GTK_CONTAINER(content), grid); + GtkWidget *scrolled = gtk_scrolled_window_new(NULL, NULL); + gtk_widget_set_hexpand(scrolled, TRUE); + gtk_widget_set_vexpand(scrolled, TRUE); + gtk_container_add(GTK_CONTAINER(grid), scrolled); + + model = gtk_list_store_new(3, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING); + GtkWidget *tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(model)); + gtk_widget_set_hexpand(tree, TRUE); + gtk_widget_set_vexpand(tree, TRUE); + AtkObject *tree_accessible = gtk_widget_get_accessible(tree); + if (tree_accessible) atk_object_set_name(tree_accessible, title ? title : "Notifications"); + for (int column = 0; column < 2; column++) { + const char *header = column == 0 ? message_header : received_header; + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn *view = gtk_tree_view_column_new_with_attributes(header, renderer, + "text", column, NULL); + if (header && header[0]) gtk_tree_view_column_set_sort_column_id(view, column); + gtk_tree_view_append_column(GTK_TREE_VIEW(tree), view); + } + selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree)); + gtk_tree_selection_set_mode(selection, GTK_SELECTION_SINGLE); + g_signal_connect(selection, "changed", G_CALLBACK(selection_changed), NULL); + gtk_container_add(GTK_CONTAINER(scrolled), tree); + + GtkWidget *frame = gtk_frame_new("Notification Actions"); + gtk_frame_set_label_align(GTK_FRAME(frame), 0.0, 0.5); + AtkObject *frame_accessible = gtk_widget_get_accessible(frame); + if (frame_accessible) atk_object_set_name(frame_accessible, "Notification Actions"); + gtk_grid_attach_next_to(GTK_GRID(grid), frame, scrolled, GTK_POS_BOTTOM, 1, 1); + GtkWidget *actions_content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + g_object_set(actions_content, "margin-top", 6, "margin-bottom", 6, + "margin-start", 6, "margin-end", 6, NULL); + gtk_container_add(GTK_CONTAINER(frame), actions_content); + actions_status = gtk_label_new(""); + gtk_label_set_xalign(GTK_LABEL(actions_status), 0); + gtk_label_set_line_wrap(GTK_LABEL(actions_status), TRUE); + gtk_widget_set_no_show_all(actions_status, TRUE); + gtk_box_pack_start(GTK_BOX(actions_content), actions_status, FALSE, FALSE, 0); + actions_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_pack_start(GTK_BOX(actions_content), actions_box, FALSE, FALSE, 0); + return 1; +} + +void notification_list_set_entries(char **messages, char **received, int count, + char **ids, const char *selected, int can_control, + char **action_keys, char **action_labels, + int action_count) { + if (!model) return; + suppress_selection = 1; + gtk_list_store_clear(model); + for (int index = 0; index < count; index++) { + GtkTreeIter iterator; + gtk_list_store_append(model, &iterator); + gtk_list_store_set(model, &iterator, 0, messages && messages[index] ? messages[index] : "", + 1, received && received[index] ? received[index] : "", + 2, ids && ids[index] ? ids[index] : "", -1); + } + GtkTreeIter iterator; + gboolean found = FALSE; + if (gtk_tree_model_get_iter_first(GTK_TREE_MODEL(model), &iterator)) { + do { + char *value = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(model), &iterator, 2, &value, -1); + if (value && selected && !g_strcmp0(value, selected)) found = TRUE; + g_free(value); + if (found) { + GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(model), &iterator); + gtk_tree_selection_select_path(selection, path); + gtk_tree_path_free(path); + break; + } + } while (gtk_tree_model_iter_next(GTK_TREE_MODEL(model), &iterator)); + } + if (!found) gtk_tree_selection_unselect_all(selection); + gtk_widget_set_sensitive(dismiss_button, can_control != 0); + if (!can_control) { + clear_action_buttons(); + gtk_label_set_text(GTK_LABEL(actions_status), "Notification is no longer available"); + gtk_widget_show(actions_status); + } else if (!action_count) { + clear_action_buttons(); + gtk_label_set_text(GTK_LABEL(actions_status), "No notification actions available"); + gtk_widget_show(actions_status); + } else { + set_actions(action_keys, action_labels, action_count); + gtk_label_set_text(GTK_LABEL(actions_status), ""); + gtk_widget_hide(actions_status); + } + suppress_selection = 0; +} + +void notification_list_show(void) { + if (!dialog) return; + gtk_widget_show_all(dialog); + gtk_window_present(GTK_WINDOW(dialog)); +} + +void notification_list_present(void) { + if (!dialog) return; + gtk_window_present(GTK_WINDOW(dialog)); +} + +void notification_list_iterate(void) { + while (gtk_events_pending()) gtk_main_iteration(); +} + +int notification_list_action(void) { + int result = action_code; + action_code = ACTION_NONE; + return result; +} + +int notification_list_selected(void) { + GtkTreeIter iterator; + GtkTreeModel *selected_model = NULL; + if (!selection || !gtk_tree_selection_get_selected(selection, &selected_model, &iterator)) return -1; + GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(model), &iterator); + if (!path) return -1; + int *indices = gtk_tree_path_get_indices(path); + int selected = indices && indices[0] >= 0 ? indices[0] : -1; + gtk_tree_path_free(path); + return selected; +} + +int notification_list_selected_row(void) { + int selected = notification_list_selected(); + return selected >= 0 ? selected : 0; +} + +const char *notification_list_selected_id(void) { + GtkTreeIter iterator; + GtkTreeModel *selected_model = NULL; + if (!selection || !gtk_tree_selection_get_selected(selection, &selected_model, &iterator)) return ""; + char *identifier = NULL; + gtk_tree_model_get(selected_model, &iterator, 2, &identifier, -1); + g_free(selected_id_cache); + selected_id_cache = identifier ? identifier : g_strdup(""); + return selected_id_cache; +} + +const char *notification_list_selected_action(void) { + return selected_action_key ? selected_action_key : ""; +} + +int notification_list_copy(void) { + GtkTreeIter iterator; + GtkTreeModel *selected_model = NULL; + if (!selection || !gtk_tree_selection_get_selected(selection, &selected_model, &iterator)) return 0; + char *message = NULL; + char *received = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(model), &iterator, 0, &message, 1, &received, -1); + char *text = g_strjoin("\t", message ? message : "", received && received[0] ? received : NULL, NULL); + GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + gtk_clipboard_set_text(clipboard, text, -1); + gtk_clipboard_store(clipboard); + g_free(text); + g_free(message); + g_free(received); + return 1; +} + +void notification_list_close(void) { + if (!dialog) return; + gtk_widget_destroy(dialog); + dialog = NULL; + model = NULL; + selection = NULL; + dismiss_button = NULL; + actions_box = NULL; + actions_status = NULL; + g_free(selected_action_key); + selected_action_key = NULL; + g_free(selected_id_cache); + selected_id_cache = NULL; +} diff --git a/internal/notificationui/native.h b/internal/notificationui/native.h new file mode 100644 index 0000000..139a594 --- /dev/null +++ b/internal/notificationui/native.h @@ -0,0 +1,21 @@ +#ifndef CTHULHU_NOTIFICATION_UI_NATIVE_H +#define CTHULHU_NOTIFICATION_UI_NATIVE_H + +int notification_list_init(const char *title, const char *message_header, + const char *received_header); +void notification_list_set_entries(char **messages, char **received, int count, + char **ids, const char *selected, int can_control, + char **action_keys, char **action_labels, + int action_count); +void notification_list_show(void); +void notification_list_present(void); +void notification_list_iterate(void); +int notification_list_action(void); +int notification_list_selected(void); +int notification_list_selected_row(void); +const char *notification_list_selected_id(void); +const char *notification_list_selected_action(void); +int notification_list_copy(void); +void notification_list_close(void); + +#endif diff --git a/internal/notificationui/protocol.go b/internal/notificationui/protocol.go new file mode 100644 index 0000000..a3cab46 --- /dev/null +++ b/internal/notificationui/protocol.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package notificationui + +type Action struct { + Key string `json:"key"` + Label string `json:"label"` +} + +type Entry struct { + ID string `json:"id,omitempty"` + Message string `json:"message"` + Received string `json:"received"` + CanControl bool `json:"canControl"` + Actions []Action `json:"actions"` +} + +type Response struct { + Present bool `json:"present,omitempty"` + Close bool `json:"close,omitempty"` + Title string `json:"title,omitempty"` + Entries []Entry `json:"entries"` + Selected string `json:"selected"` +} + +type Request struct { + Selection string `json:"selection,omitempty"` + Row *int `json:"row,omitempty"` + Copy bool `json:"copy,omitempty"` + Dismiss bool `json:"dismiss,omitempty"` + Clear bool `json:"clear,omitempty"` + Action string `json:"action,omitempty"` +} diff --git a/internal/notificationui/ui.go b/internal/notificationui/ui.go new file mode 100644 index 0000000..9fa0841 --- /dev/null +++ b/internal/notificationui/ui.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package notificationui + +/* +#cgo pkg-config: gtk+-3.0 +#include +#include "native.h" +*/ +import "C" + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "runtime" + "time" + "unsafe" +) + +// Run owns all GTK calls on one OS thread. The parent supplies a private pipe; +// no widget invokes speech directly. +func Run(ctx context.Context, input io.Reader, output io.Writer) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + updates := make(chan Response, 1) + errorsIn := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 4096), 1024*1024) + for scanner.Scan() { + var response Response + if err := json.Unmarshal(scanner.Bytes(), &response); err != nil { + errorsIn <- err + return + } + select { + case updates <- response: + case <-ctx.Done(): + return + } + } + errorsIn <- scanner.Err() + }() + + var initial Response + select { + case initial = <-updates: + case err := <-errorsIn: + if errors.Is(err, io.EOF) { + return nil + } + return err + case <-ctx.Done(): + return ctx.Err() + } + title := C.CString(initial.Title) + defer C.free(unsafe.Pointer(title)) + messageHeader := C.CString("Notifications") + defer C.free(unsafe.Pointer(messageHeader)) + receivedHeader := C.CString("Received") + defer C.free(unsafe.Pointer(receivedHeader)) + if C.notification_list_init(title, messageHeader, receivedHeader) == 0 { + return errors.New("cannot open the notification list on this display") + } + defer C.notification_list_close() + apply(initial) + C.notification_list_show() + + encoder := json.NewEncoder(output) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errorsIn: + if errors.Is(err, io.EOF) { + return nil + } + return err + case response := <-updates: + if response.Close { + return nil + } + if response.Present { + C.notification_list_present() + continue + } + apply(response) + case <-ticker.C: + C.notification_list_iterate() + switch C.notification_list_action() { + case 1: + if C.notification_list_copy() != 0 { + if err := encoder.Encode(Request{Copy: true}); err != nil { + return err + } + } + case 2: + selection := C.GoString(C.notification_list_selected_id()) + row := int(C.notification_list_selected_row()) + if err := encoder.Encode(Request{Dismiss: true, Selection: selection, Row: &row}); err != nil { + return err + } + case 3: + if err := encoder.Encode(Request{Clear: true}); err != nil { + return err + } + case 4: + return nil + case 5: + selection := C.GoString(C.notification_list_selected_id()) + row := int(C.notification_list_selected_row()) + if err := encoder.Encode(Request{Selection: selection, Row: &row}); err != nil { + return err + } + case 6: + action := C.GoString(C.notification_list_selected_action()) + selection := C.GoString(C.notification_list_selected_id()) + row := int(C.notification_list_selected_row()) + if err := encoder.Encode(Request{Action: action, Selection: selection, Row: &row}); err != nil { + return err + } + } + } + } +} + +func apply(response Response) { + messages := make([]*C.char, len(response.Entries)) + received := make([]*C.char, len(response.Entries)) + identifiers := make([]*C.char, len(response.Entries)) + for index, entry := range response.Entries { + identifiers[index] = C.CString(entry.ID) + messages[index] = C.CString(entry.Message) + received[index] = C.CString(entry.Received) + } + defer func() { + for _, value := range messages { + C.free(unsafe.Pointer(value)) + } + for _, value := range received { + C.free(unsafe.Pointer(value)) + } + for _, value := range identifiers { + C.free(unsafe.Pointer(value)) + } + }() + + selectedID := C.CString(response.Selected) + defer C.free(unsafe.Pointer(selectedID)) + var selectedEntry Entry + for _, entry := range response.Entries { + if entry.ID == response.Selected { + selectedEntry = entry + break + } + } + actionKeys := make([]*C.char, len(selectedEntry.Actions)) + actionLabels := make([]*C.char, len(selectedEntry.Actions)) + for index, action := range selectedEntry.Actions { + actionKeys[index] = C.CString(action.Key) + actionLabels[index] = C.CString(action.Label) + } + defer func() { + for _, value := range actionKeys { + C.free(unsafe.Pointer(value)) + } + for _, value := range actionLabels { + C.free(unsafe.Pointer(value)) + } + }() + + C.notification_list_set_entries( + cStringArray(messages), cStringArray(received), C.int(len(response.Entries)), + cStringArray(identifiers), selectedID, cBoolean(selectedEntry.CanControl), + cStringArray(actionKeys), cStringArray(actionLabels), C.int(len(selectedEntry.Actions)), + ) +} + +func cStringArray(values []*C.char) **C.char { + if len(values) == 0 { + return nil + } + return (**C.char)(unsafe.Pointer(&values[0])) +} + +func cBoolean(value bool) C.int { + if value { + return 1 + } + return 0 +} diff --git a/internal/nvda2cthulhu/messagepack.go b/internal/nvda2cthulhu/messagepack.go new file mode 100644 index 0000000..e475b42 --- /dev/null +++ b/internal/nvda2cthulhu/messagepack.go @@ -0,0 +1,210 @@ +// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA +// compatibility bridges and routes it to Cthulhu's speech and braille queues. +package nvda2cthulhu + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "math" +) + +type messagePackBinary string + +func decodeMessagePack(reader *bytes.Reader) (any, error) { + return decodeMessagePackDepth(reader, 0) +} + +func decodeMessagePackDepth(reader *bytes.Reader, depth int) (any, error) { + if depth > 64 { + return nil, errors.New("MessagePack nesting exceeds limit") + } + code, err := reader.ReadByte() + if err != nil { + return nil, err + } + switch { + case code <= 0x7f: + return int64(code), nil + case code >= 0xe0: + return int64(int8(code)), nil + case code >= 0x80 && code <= 0x8f: + return decodeMessagePackMap(reader, int(code&0x0f), depth) + case code >= 0x90 && code <= 0x9f: + return decodeMessagePackArray(reader, int(code&0x0f), depth) + case code >= 0xa0 && code <= 0xbf: + return decodeMessagePackString(reader, int(code&0x1f)) + case code == 0xc0: + return nil, nil + case code == 0xc2: + return false, nil + case code == 0xc3: + return true, nil + case code == 0xc4 || code == 0xc5 || code == 0xc6: + length, err := decodeLength(reader, code, 0xc4) + if err != nil { + return nil, err + } + value := make([]byte, length) + if _, err := io.ReadFull(reader, value); err != nil { + return nil, err + } + return messagePackBinary(value), nil + case code >= 0xca && code <= 0xcb: + return decodeMessagePackFloat(reader, code) + case code >= 0xcc && code <= 0xd3: + return decodeMessagePackInteger(reader, code) + case code == 0xd9 || code == 0xda || code == 0xdb: + length, err := decodeLength(reader, code, 0xd9) + if err != nil { + return nil, err + } + return decodeMessagePackString(reader, length) + case code == 0xdc || code == 0xdd: + length, err := decodeLength(reader, code, 0xdc) + if err != nil { + return nil, err + } + return decodeMessagePackArray(reader, length, depth) + case code == 0xde || code == 0xdf: + length, err := decodeLength(reader, code, 0xde) + if err != nil { + return nil, err + } + return decodeMessagePackMap(reader, length, depth) + default: + return nil, fmt.Errorf("unsupported MessagePack type 0x%02x", code) + } +} + +func decodeLength(reader *bytes.Reader, code, base byte) (int, error) { + var size int + switch code - base { + case 0: + size = 1 + case 1: + size = 2 + case 2: + size = 4 + default: + return 0, fmt.Errorf("invalid length type 0x%02x", code) + } + buffer := make([]byte, size) + if _, err := io.ReadFull(reader, buffer); err != nil { + return 0, err + } + value := uint64(0) + for _, octet := range buffer { + value = value<<8 | uint64(octet) + } + if value > uint64(maxPayload) { + return 0, errors.New("MessagePack value exceeds 64 KiB") + } + return int(value), nil +} + +func decodeMessagePackString(reader *bytes.Reader, length int) (string, error) { + value := make([]byte, length) + if _, err := io.ReadFull(reader, value); err != nil { + return "", err + } + return string(value), nil +} + +func decodeMessagePackFloat(reader *bytes.Reader, code byte) (any, error) { + var buffer []byte + if code == 0xca { + buffer = make([]byte, 4) + } else { + buffer = make([]byte, 8) + } + if _, err := io.ReadFull(reader, buffer); err != nil { + return nil, err + } + if code == 0xca { + bits := binary.BigEndian.Uint32(buffer) + return float64(math.Float32frombits(bits)), nil + } + return math.Float64frombits(binary.BigEndian.Uint64(buffer)), nil +} + +func decodeMessagePackInteger(reader *bytes.Reader, code byte) (any, error) { + switch code { + case 0xcc, 0xd0: + buffer := make([]byte, 1) + if _, err := io.ReadFull(reader, buffer); err != nil { + return nil, err + } + if code == 0xcc { + return uint64(buffer[0]), nil + } + return int64(int8(buffer[0])), nil + case 0xcd, 0xd1: + buffer := make([]byte, 2) + if _, err := io.ReadFull(reader, buffer); err != nil { + return nil, err + } + value := binary.BigEndian.Uint16(buffer) + if code == 0xcd { + return uint64(value), nil + } + return int64(int16(value)), nil + case 0xce, 0xd2: + buffer := make([]byte, 4) + if _, err := io.ReadFull(reader, buffer); err != nil { + return nil, err + } + value := binary.BigEndian.Uint32(buffer) + if code == 0xce { + return uint64(value), nil + } + return int64(int32(value)), nil + case 0xcf, 0xd3: + buffer := make([]byte, 8) + if _, err := io.ReadFull(reader, buffer); err != nil { + return nil, err + } + value := binary.BigEndian.Uint64(buffer) + if code == 0xcf { + return value, nil + } + return int64(value), nil + default: + return nil, fmt.Errorf("invalid integer type 0x%02x", code) + } +} + +func decodeMessagePackArray(reader *bytes.Reader, length, depth int) ([]any, error) { + values := make([]any, length) + for index := range values { + value, err := decodeMessagePackDepth(reader, depth+1) + if err != nil { + return nil, err + } + values[index] = value + } + return values, nil +} + +func decodeMessagePackMap(reader *bytes.Reader, length, depth int) (map[any]any, error) { + values := make(map[any]any, length) + for range length { + key, err := decodeMessagePackDepth(reader, depth+1) + if err != nil { + return nil, err + } + switch key.(type) { + case string, messagePackBinary: + default: + return nil, errors.New("MessagePack map key must be text") + } + value, err := decodeMessagePackDepth(reader, depth+1) + if err != nil { + return nil, err + } + values[key] = value + } + return values, nil +} diff --git a/internal/nvda2cthulhu/messagepack_test.go b/internal/nvda2cthulhu/messagepack_test.go new file mode 100644 index 0000000..853602e --- /dev/null +++ b/internal/nvda2cthulhu/messagepack_test.go @@ -0,0 +1,44 @@ +package nvda2cthulhu + +import ( + "bytes" + "testing" + + "github.com/gorilla/websocket" +) + +func TestDecodeMessagePackRequests(t *testing.T) { + tests := []struct { + name string + payload []byte + want bridgeRequest + }{ + {name: "fix string", payload: []byte{0xa4, 'G', 'a', 'm', 'e'}, want: bridgeRequest{kind: "SpeakText", text: "Game"}}, + {name: "map with string", payload: []byte{0x81, 0xa9, 'S', 'p', 'e', 'a', 'k', 'T', 'e', 'x', 't', 0xa4, 'G', 'a', 'm', 'e'}, want: bridgeRequest{kind: "SpeakText", text: "Game"}}, + {name: "array cancel", payload: []byte{0x91, 0xac, 'C', 'a', 'n', 'c', 'e', 'l', 'S', 'p', 'e', 'e', 'c', 'h'}, want: bridgeRequest{kind: "CancelSpeech"}}, + {name: "bin value", payload: []byte{0x81, 0xab, 'B', 'r', 'a', 'i', 'l', 'l', 'e', 'T', 'e', 'x', 't', 0xc4, 0x02, 'O', 'K'}, want: bridgeRequest{kind: "BrailleText", text: "OK"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := decodeRequest(websocket.BinaryMessage, test.payload) + if err != nil { + t.Fatalf("decodeRequest() error = %v", err) + } + if got.kind != test.want.kind || got.text != test.want.text { + t.Fatalf("decodeRequest() = %+v, want %+v", got, test.want) + } + }) + } +} + +func TestDecodeMessagePackRejectsMalformedContainers(t *testing.T) { + for _, payload := range [][]byte{ + {0x81, 0x90, 0xc0}, // An array key cannot be inserted into a Go map. + append(append([]byte{}, bytes.Repeat([]byte{0x91}, 66)...), 0xc0), + {0xa2, 'O', 'K', 0xc0}, // One WebSocket frame contains exactly one request. + } { + if _, err := decodeRequest(websocket.BinaryMessage, payload); err == nil { + t.Fatalf("accepted malformed MessagePack payload %x", payload) + } + } +} diff --git a/internal/nvda2cthulhu/protocol.go b/internal/nvda2cthulhu/protocol.go new file mode 100644 index 0000000..98375f2 --- /dev/null +++ b/internal/nvda2cthulhu/protocol.go @@ -0,0 +1,154 @@ +// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA +// compatibility bridges and routes it to Cthulhu's speech and braille queues. +package nvda2cthulhu + +import ( + "bytes" + "errors" + "strconv" + "strings" + + "github.com/gorilla/websocket" +) + +func (server *Server) deliver(request bridgeRequest) { + switch request.kind { + case "SpeakText": + if server.handler.SpeakText(request.text, true) { + server.logger.Debug("NVDA to Cthulhu speech accepted", "bytes", len(request.text)) + } else { + server.logger.Warn("NVDA to Cthulhu speech rejected", "bytes", len(request.text)) + } + case "BrailleText": + if server.handler.BrailleMessage(request.text) { + server.logger.Debug("NVDA to Cthulhu braille accepted", "bytes", len(request.text)) + } else { + server.logger.Debug("NVDA to Cthulhu braille unavailable") + } + case "CancelSpeech": + if server.handler.CancelSpeech() { + server.logger.Debug("NVDA to Cthulhu cancellation accepted") + } else { + server.logger.Warn("NVDA to Cthulhu cancellation rejected") + } + } +} + +type bridgeRequest struct { + kind, text string +} + +func decodeRequest(messageType int, payload []byte) (bridgeRequest, error) { + if len(payload) > maxPayload { + return bridgeRequest{}, errors.New("message exceeds 64 KiB") + } + switch messageType { + case websocket.TextMessage: + return parseRequest(string(payload)) + case websocket.BinaryMessage: + reader := bytes.NewReader(payload) + value, err := decodeMessagePack(reader) + if err != nil { + return bridgeRequest{}, err + } + if reader.Len() != 0 { + return bridgeRequest{}, errors.New("trailing MessagePack data") + } + return parseRequest(value) + default: + return bridgeRequest{}, errors.New("unsupported WebSocket message type") + } +} + +func parseRequest(value any) (bridgeRequest, error) { + switch payload := value.(type) { + case string: + if strings.TrimSpace(payload) == "" { + return bridgeRequest{}, errors.New("blank text request") + } + if payload == "CancelSpeech" { + return bridgeRequest{kind: "CancelSpeech"}, nil + } + return bridgeRequest{kind: "SpeakText", text: payload}, nil + case map[any]any: + if len(payload) != 1 { + return bridgeRequest{}, errors.New("map request must contain exactly one entry") + } + for key, value := range payload { + return keyedRequest(coerceText(key), value) + } + case map[string]any: + if len(payload) != 1 { + return bridgeRequest{}, errors.New("map request must contain exactly one entry") + } + for key, value := range payload { + return keyedRequest(key, value) + } + case []any: + if len(payload) == 0 { + return bridgeRequest{}, errors.New("array request is empty") + } + kind := coerceText(payload[0]) + if kind == "CancelSpeech" { + return bridgeRequest{kind: "CancelSpeech"}, nil + } + if len(payload) < 2 { + return bridgeRequest{}, errors.New("array request has no value") + } + return keyedRequest(kind, payload[1]) + } + return bridgeRequest{}, errors.New("unsupported MessagePack payload") +} + +func keyedRequest(kind string, value any) (bridgeRequest, error) { + switch kind { + case "CancelSpeech": + return bridgeRequest{kind: "CancelSpeech"}, nil + case "SpeakText", "BrailleText": + if strings.TrimSpace(coerceText(value)) == "" { + return bridgeRequest{}, errors.New("blank text request") + } + return bridgeRequest{kind: kind, text: coerceText(value)}, nil + default: + return bridgeRequest{}, errors.New("unsupported request kind") + } +} + +func coerceText(value any) string { + switch text := value.(type) { + case string: + return text + case messagePackBinary: + return string(text) + case nil: + return "" + case bool: + return strconv.FormatBool(text) + case int: + return strconv.Itoa(text) + case int8: + return strconv.FormatInt(int64(text), 10) + case int16: + return strconv.FormatInt(int64(text), 10) + case int32: + return strconv.FormatInt(int64(text), 10) + case int64: + return strconv.FormatInt(text, 10) + case uint: + return strconv.FormatUint(uint64(text), 10) + case uint8: + return strconv.FormatUint(uint64(text), 10) + case uint16: + return strconv.FormatUint(uint64(text), 10) + case uint32: + return strconv.FormatUint(uint64(text), 10) + case uint64: + return strconv.FormatUint(text, 10) + case float32: + return strconv.FormatFloat(float64(text), 'g', -1, 32) + case float64: + return strconv.FormatFloat(text, 'g', -1, 64) + default: + return "" + } +} diff --git a/internal/nvda2cthulhu/protocol_test.go b/internal/nvda2cthulhu/protocol_test.go new file mode 100644 index 0000000..f2483eb --- /dev/null +++ b/internal/nvda2cthulhu/protocol_test.go @@ -0,0 +1,40 @@ +package nvda2cthulhu + +import ( + "testing" +) + +func TestParseRequestMatchesPythonCompatibilityProtocol(t *testing.T) { + tests := []struct { + name string + value any + want bridgeRequest + }{ + {name: "text", value: "Game text", want: bridgeRequest{kind: "SpeakText", text: "Game text"}}, + {name: "cancel string", value: "CancelSpeech", want: bridgeRequest{kind: "CancelSpeech"}}, + {name: "speech map", value: map[any]any{"SpeakText": "Map text"}, want: bridgeRequest{kind: "SpeakText", text: "Map text"}}, + {name: "braille map", value: map[string]any{"BrailleText": "Braille"}, want: bridgeRequest{kind: "BrailleText", text: "Braille"}}, + {name: "cancel map", value: map[any]any{"CancelSpeech": nil}, want: bridgeRequest{kind: "CancelSpeech"}}, + {name: "speech array", value: []any{"SpeakText", "Array text"}, want: bridgeRequest{kind: "SpeakText", text: "Array text"}}, + {name: "cancel array", value: []any{"CancelSpeech"}, want: bridgeRequest{kind: "CancelSpeech"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parseRequest(test.value) + if err != nil { + t.Fatalf("parseRequest() error = %v", err) + } + if got.kind != test.want.kind || got.text != test.want.text { + t.Fatalf("parseRequest() = %+v, want %+v", got, test.want) + } + }) + } +} + +func TestParseRequestRejectsUnknownAndMalformedPayloads(t *testing.T) { + for _, value := range []any{"", map[any]any{}, map[any]any{"Two": "values", "SpeakText": "text"}, []any{}, []any{"Unknown", "value"}} { + if request, err := parseRequest(value); err == nil { + t.Fatalf("parseRequest(%#v) = %+v, want error", value, request) + } + } +} diff --git a/internal/nvda2cthulhu/server.go b/internal/nvda2cthulhu/server.go new file mode 100644 index 0000000..262eb2a --- /dev/null +++ b/internal/nvda2cthulhu/server.go @@ -0,0 +1,196 @@ +// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA +// compatibility bridges and routes it to Cthulhu's speech and braille queues. +package nvda2cthulhu + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + defaultPort = 3457 + hostEnv = "NVDA2SPEECHD_HOST" + maxPayload = 64 * 1024 +) + +// Handler accepts already-decoded bridge requests. Implementations must +// enqueue backend work instead of waiting for speech or braille output. +type Handler interface { + SpeakText(string, bool) bool + BrailleMessage(string) bool + CancelSpeech() bool +} + +// Server owns the local bridge listener and active WebSocket connections. +type Server struct { + logger *slog.Logger + handler Handler + connections map[*websocket.Conn]struct{} + connectionWait sync.WaitGroup + stopOnce sync.Once + stopped chan struct{} + + mutex sync.Mutex + server *http.Server +} + +// New creates a bridge server. A nil logger discards diagnostics. +func New(logger *slog.Logger, handler Handler) *Server { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &Server{ + logger: logger, + handler: handler, + connections: make(map[*websocket.Conn]struct{}), + stopped: make(chan struct{}), + } +} + +// Start binds the same local address used by Python Cthulhu. The listener is +// established before Start returns, while connection handling stays async. +func (server *Server) Start(ctx context.Context) error { + listener, err := net.Listen("tcp", bindAddress()) + if err != nil { + return fmt.Errorf("bind NVDA to Cthulhu bridge: %w", err) + } + httpServer := &http.Server{ + Handler: http.HandlerFunc(server.handleHTTP), + ReadHeaderTimeout: 5 * time.Second, + } + server.mutex.Lock() + server.server = httpServer + server.mutex.Unlock() + go func() { + if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + server.logger.Warn("NVDA to Cthulhu server stopped", "error", err) + } + }() + server.logger.Info("NVDA to Cthulhu listening", "address", bindAddress()) + return nil +} + +// Stop closes the listener and active connections. +func (server *Server) Stop(ctx context.Context) error { + server.stopOnce.Do(func() { + close(server.stopped) + }) + server.mutex.Lock() + httpServer := server.server + connections := make([]*websocket.Conn, 0, len(server.connections)) + for connection := range server.connections { + connections = append(connections, connection) + } + server.mutex.Unlock() + for _, connection := range connections { + _ = connection.Close() + } + if httpServer == nil { + return server.waitConnections(ctx) + } + if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("stop NVDA to Cthulhu bridge: %w", err) + } + return server.waitConnections(ctx) +} + +func (server *Server) handleHTTP(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/" { + http.NotFound(response, request) + return + } + upgrader := websocket.Upgrader{ + CheckOrigin: allowedOrigin, + } + connection, err := upgrader.Upgrade(response, request, nil) + if err != nil { + server.logger.Warn("NVDA to Cthulhu connection rejected", "error", err) + return + } + defer connection.Close() + server.mutex.Lock() + select { + case <-server.stopped: + server.mutex.Unlock() + return + default: + server.connectionWait.Add(1) + server.connections[connection] = struct{}{} + server.mutex.Unlock() + } + defer func() { + server.mutex.Lock() + delete(server.connections, connection) + server.mutex.Unlock() + server.connectionWait.Done() + }() + connection.SetReadLimit(maxPayload) + server.logger.Debug("NVDA to Cthulhu client connected", "remote", connection.RemoteAddr().String()) + for { + messageType, payload, err := connection.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + server.logger.Debug("NVDA to Cthulhu client disconnected", "error", err) + } + return + } + if len(payload) > maxPayload { + server.logger.Warn("NVDA to Cthulhu message too large", "size", len(payload)) + return + } + request, err := decodeRequest(messageType, payload) + if err != nil { + server.logger.Warn("NVDA to Cthulhu rejected message", "error", err) + continue + } + server.deliver(request) + } +} + +func (server *Server) waitConnections(ctx context.Context) error { + done := make(chan struct{}) + go func() { + server.connectionWait.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func bindAddress() string { + port := defaultPort + if value := os.Getenv(hostEnv); value != "" { + if parsed, err := url.Parse(value); err == nil && parsed.Port() != "" { + port, _ = strconv.Atoi(parsed.Port()) + } + } + return net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) +} + +func allowedOrigin(request *http.Request) bool { + origin := request.Header.Get("Origin") + if origin == "" { + return true // Native bridge clients do not send a browser origin. + } + parsed, err := url.Parse(origin) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return false + } + host := parsed.Hostname() + return host == "localhost" || net.ParseIP(host).IsLoopback() +} diff --git a/internal/nvda2cthulhu/server_test.go b/internal/nvda2cthulhu/server_test.go new file mode 100644 index 0000000..4e2137f --- /dev/null +++ b/internal/nvda2cthulhu/server_test.go @@ -0,0 +1,171 @@ +package nvda2cthulhu + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +type recordingHandler struct { + mutex sync.Mutex + spoken []string + interrupt []bool + braille []string + canceled int +} + +func (handler *recordingHandler) SpeakText(text string, interrupt bool) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.spoken = append(handler.spoken, text) + handler.interrupt = append(handler.interrupt, interrupt) + return true +} + +func (handler *recordingHandler) BrailleMessage(text string) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.braille = append(handler.braille, text) + return true +} + +func (handler *recordingHandler) CancelSpeech() bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.canceled++ + return true +} + +func TestAllowedOriginAcceptsNativeAndLoopbackClients(t *testing.T) { + for _, test := range []struct { + origin string + want bool + }{ + {"", true}, + {"http://127.0.0.1:3457", true}, + {"http://localhost:3457", true}, + {"https://[::1]:3457", true}, + {"https://example.com", false}, + {"null", false}, + } { + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:3457/", nil) + if test.origin != "" { + request.Header.Set("Origin", test.origin) + } + if got := allowedOrigin(request); got != test.want { + t.Errorf("allowedOrigin(%q) = %v, want %v", test.origin, got, test.want) + } + } +} + +func liveSocketTest(t *testing.T) { + t.Helper() + handler := &recordingHandler{} + server := New(nil, handler) + httpTest := httptest.NewServer(http.HandlerFunc(server.handleHTTP)) + defer httpTest.Close() + url := "ws" + strings.TrimPrefix(httpTest.URL, "http") + connection, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatalf("dial WebSocket: %v", err) + } + defer connection.Close() + + if err := connection.WriteMessage(websocket.TextMessage, []byte("Accessibility mod loaded.")); err != nil { + t.Fatalf("write speech text: %v", err) + } + if err := connection.WriteMessage(websocket.BinaryMessage, []byte{0x81, 0xab, 'B', 'r', 'a', 'i', 'l', 'l', 'e', 'T', 'e', 'x', 't', 0xa2, 'O', 'K'}); err != nil { + t.Fatalf("write braille text: %v", err) + } + if err := connection.WriteMessage(websocket.TextMessage, []byte("CancelSpeech")); err != nil { + t.Fatalf("write cancellation: %v", err) + } + + waitForHandlerCalls(t, handler) + handler.mutex.Lock() + defer handler.mutex.Unlock() + if len(handler.spoken) != 1 || handler.spoken[0] != "Accessibility mod loaded." || len(handler.interrupt) != 1 || !handler.interrupt[0] { + t.Fatalf("speech calls = %v, interrupts = %v", handler.spoken, handler.interrupt) + } + if len(handler.braille) != 1 || handler.braille[0] != "OK" { + t.Fatalf("braille calls = %v", handler.braille) + } + if handler.canceled != 1 { + t.Fatalf("cancellations = %d, want 1", handler.canceled) + } +} + +func TestServerHandlesWebSocketRequests(t *testing.T) { + if os.Getenv("CTHULHU_LIVE_SOCKET_TEST") != "1" { + t.Skip("set CTHULHU_LIVE_SOCKET_TEST=1 to allow a local TCP listener") + } + liveSocketTest(t) +} + +func TestStartStopClosesActiveConnection(t *testing.T) { + if os.Getenv("CTHULHU_LIVE_SOCKET_TEST") != "1" { + t.Skip("set CTHULHU_LIVE_SOCKET_TEST=1 to allow a local TCP listener") + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("find port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("release port: %v", err) + } + t.Setenv(hostEnv, fmt.Sprintf("http://127.0.0.1:%d", port)) + + server := New(nil, &recordingHandler{}) + if err := server.Start(context.Background()); err != nil { + t.Fatalf("start server: %v", err) + } + connection, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d", port), nil) + if err != nil { + t.Fatalf("dial server: %v", err) + } + defer connection.Close() + if err := server.Stop(context.Background()); err != nil { + t.Fatalf("stop server: %v", err) + } + if _, _, err := connection.ReadMessage(); err == nil { + t.Fatal("active connection remained open after Stop") + } +} + +func waitForHandlerCalls(t *testing.T, handler *recordingHandler) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + handler.mutex.Lock() + done := len(handler.spoken) == 1 && len(handler.braille) == 1 && handler.canceled == 1 + handler.mutex.Unlock() + if done { + return + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestBindAddressUsesPythonDefault(t *testing.T) { + t.Setenv(hostEnv, "http://127.0.0.1") + if address := bindAddress(); address != "127.0.0.1:3457" { + t.Fatalf("default address = %q", address) + } +} + +func TestStopWithoutStartSucceeds(t *testing.T) { + server := New(nil, nil) + if err := server.Stop(context.Background()); err != nil { + t.Fatalf("stop unstarted server: %v", err) + } +} diff --git a/internal/ocr/capture.go b/internal/ocr/capture.go new file mode 100644 index 0000000..41735b8 --- /dev/null +++ b/internal/ocr/capture.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "image" + "image/png" + "io" + "os/exec" + "time" +) + +var ( + ErrCapture = errors.New("OCR capture unavailable") + ErrSource = errors.New("OCR source unavailable") + ErrSourceChanged = errors.New("OCR source changed") +) + +type SourceKind string + +const ( + ObjectSource SourceKind = "object" + WindowSource SourceKind = "window" + DesktopSource SourceKind = "desktop" + ClipboardSource SourceKind = "clipboard" + maxCaptureHeader = 4096 + captureTimeout = 2 * time.Second +) + +// CaptureRequest is an immutable job snapshot. Bounds are physical X11 root +// pixels, not GTK logical coordinates. The future target owner must convert +// provider coordinates before requesting capture and retain its object identity. +// Generation belongs to that owner, which must retire jobs on focus/lock/loss. +// Foreground is required even for clipboard capture. Window may name a child of +// Foreground (for example a Wine client); zero selects the foreground client. +type CaptureRequest struct { + Kind SourceKind + Bounds image.Rectangle + Foreground uint64 + Window uint64 + Generation uint64 +} + +func (request CaptureRequest) Validate() error { + if request.Foreground == 0 || request.Foreground > 0xffffffff || request.Window > 0xffffffff || request.Generation == 0 { + return ErrSource + } + switch request.Kind { + case ObjectSource: + if !valid_screen_rectangle(request.Bounds) { + return ErrSource + } + case WindowSource, DesktopSource, ClipboardSource: + if request.Bounds != (image.Rectangle{}) { + return ErrSource + } + default: + return ErrSource + } + if request.Kind != WindowSource && request.Window != 0 { + return ErrSource + } + return nil +} + +// CaptureHeader precedes a PNG on the private pipe. Rectangles describe physical +// root pixels. No monitor topology, object identity or activation authorization +// is inferred from them. Clipboard pixels have no screen mapping. +type CaptureHeader struct { + Request CaptureRequest + SourceWindow uint64 + RootBounds image.Rectangle + SourceBounds image.Rectangle + ScreenBounds image.Rectangle + Status string +} + +type Capture struct { + CaptureHeader + Pixels image.Image + Transform Transform +} + +// Capturer owns one helper at a time. It performs no startup I/O. Run Capture +// on a worker, never the input/event thread. The executable must be the reader +// binary supporting --ocr-capture; it is invoked directly without a shell. +type Capturer struct { + gate chan struct{} + command func(context.Context, ...string) *exec.Cmd +} + +func NewCapturer(executable string) *Capturer { + return &Capturer{gate: make(chan struct{}, 1), command: func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, executable, args...) + }} +} + +func (capturer *Capturer) Capture(ctx context.Context, request CaptureRequest) (Capture, error) { + if err := request.Validate(); err != nil { + return Capture{}, err + } + ctx, cancel := context.WithTimeout(ctx, captureTimeout) + defer cancel() + select { + case capturer.gate <- struct{}{}: + defer func() { <-capturer.gate }() + case <-ctx.Done(): + return Capture{}, ctx.Err() + } + if err := ctx.Err(); err != nil { + return Capture{}, err + } + input, err := json.Marshal(request) + if err != nil { + return Capture{}, ErrSource + } + output, _, err := run_process(ctx, capturer.command, input, maxImageBytes+maxCaptureHeader, "--ocr-capture") + if err != nil { + if errors.Is(err, ErrUnavailable) || errors.Is(err, ErrRecognition) { + return Capture{}, ErrCapture + } + return Capture{}, err + } + capture, err := decode_capture(output, request) + if ctx.Err() != nil { + return Capture{}, ctx.Err() + } + return capture, err +} + +// ReadCaptureRequest bounds and validates the helper's one request before any +// GTK/X11 initialization. Unknown fields and trailing messages are rejected. +func ReadCaptureRequest(input io.Reader) (CaptureRequest, error) { + data, err := io.ReadAll(io.LimitReader(input, maxCaptureHeader+1)) + if err != nil || len(data) > maxCaptureHeader { + return CaptureRequest{}, ErrLimit + } + var request CaptureRequest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&request) != nil || decoder.Decode(new(any)) != io.EOF { + return CaptureRequest{}, ErrSource + } + return request, request.Validate() +} + +// WriteCaptureResponse retains a complete bounded frame before publishing it. +// Failures contain a fixed status only; native diagnostics never cross the pipe. +func WriteCaptureResponse(output io.Writer, header CaptureHeader, pixels image.Image) error { + data, err := json.Marshal(header) + if err != nil || len(data)+1 > maxCaptureHeader { + return ErrLimit + } + buffer := &bounded_buffer{ctx: context.Background(), limit: maxImageBytes + maxCaptureHeader} + _, _ = buffer.Write(append(data, '\n')) + if header.Status == "" { + if pixels == nil || !valid_dimensions(pixels.Bounds().Dx(), pixels.Bounds().Dy()) { + return ErrInvalidImage + } + if err := png.Encode(buffer, pixels); err != nil { + return err + } + } + _, err = output.Write(buffer.Bytes()) + return err +} + +func decode_capture(data []byte, request CaptureRequest) (Capture, error) { + separator := bytes.IndexByte(data, '\n') + if separator < 0 || separator >= maxCaptureHeader || len(data) > maxImageBytes+maxCaptureHeader { + return Capture{}, ErrCapture + } + var header CaptureHeader + decoder := json.NewDecoder(bytes.NewReader(data[:separator])) + decoder.DisallowUnknownFields() + if decoder.Decode(&header) != nil || decoder.Decode(new(any)) != io.EOF || header.Request != request { + return Capture{}, ErrCapture + } + if header.Status != "" { + if len(data) != separator+1 { + return Capture{}, ErrCapture + } + switch header.Status { + case "unavailable": + return Capture{}, ErrCapture + case "source": + return Capture{}, ErrSource + case "changed": + return Capture{}, ErrSourceChanged + case "limit": + return Capture{}, ErrLimit + default: + return Capture{}, ErrCapture + } + } + payload := data[separator+1:] + config, err := png.DecodeConfig(bytes.NewReader(payload)) + if err != nil { + return Capture{}, ErrCapture + } + if !valid_dimensions(config.Width, config.Height) { + return Capture{}, ErrLimit + } + transform := Transform{ImageContent: image.Rect(0, 0, config.Width, config.Height)} + if request.Kind == ClipboardSource { + if header.SourceWindow != 0 || header.RootBounds != (image.Rectangle{}) || header.SourceBounds != (image.Rectangle{}) || header.ScreenBounds != (image.Rectangle{}) { + return Capture{}, ErrCapture + } + } else { + if !valid_screen_rectangle(header.RootBounds) || !valid_screen_rectangle(header.SourceBounds) || + !valid_screen_rectangle(header.ScreenBounds) || !header.ScreenBounds.In(header.RootBounds) || !header.ScreenBounds.In(header.SourceBounds) || + header.ScreenBounds.Dx() != config.Width || header.ScreenBounds.Dy() != config.Height { + return Capture{}, ErrCapture + } + sourceWindow := request.Foreground + if request.Kind == WindowSource && request.Window != 0 { + sourceWindow = request.Window + } + if request.Kind == DesktopSource { + sourceWindow = 0 + if header.SourceBounds != header.RootBounds || header.ScreenBounds != header.RootBounds { + return Capture{}, ErrCapture + } + } else if request.Kind == ObjectSource && header.ScreenBounds != request.Bounds.Intersect(header.SourceBounds).Intersect(header.RootBounds) { + return Capture{}, ErrCapture + } + if request.Kind == WindowSource && header.ScreenBounds != header.SourceBounds.Intersect(header.RootBounds) { + return Capture{}, ErrCapture + } + if header.SourceWindow != sourceWindow { + return Capture{}, ErrCapture + } + transform.HasScreenMapping, transform.ScreenBounds = true, header.ScreenBounds + } + payloadReader := bytes.NewReader(payload) + pixels, err := png.Decode(payloadReader) + if err != nil || payloadReader.Len() != 0 { + return Capture{}, ErrCapture + } + return Capture{CaptureHeader: header, Pixels: pixels, Transform: transform}, nil +} diff --git a/internal/ocr/capture_native_test.go b/internal/ocr/capture_native_test.go new file mode 100644 index 0000000..2461da1 --- /dev/null +++ b/internal/ocr/capture_native_test.go @@ -0,0 +1,231 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "image" + "image/color" + "os" + "os/exec" + "testing" + "time" +) + +// Run only on a private display with a freshly built helper binary. +func TestNativeOCRCapture(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR_CAPTURE") != "1" { + t.Skip("private Xvfb fixture only") + } + executable := os.Getenv("CTHULHU_OCR_CAPTURE_BINARY") + if executable == "" { + t.Fatal("set CTHULHU_OCR_CAPTURE_BINARY to a freshly built reader") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/ocr_capture_fixture.py") + fixture.Env = append(os.Environ(), "NO_AT_BRIDGE=1", "GTK_MODULES=") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = stdin.Close(); _ = fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("fixture did not start") + } + var windows map[string]uint64 + if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil { + t.Fatal(err) + } + command := func(command map[string]any) map[string]uint64 { + t.Helper() + data, _ := json.Marshal(command) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() { + t.Fatal("fixture did not respond") + } + var state map[string]uint64 + if err := json.Unmarshal(scanner.Bytes(), &state); err != nil { + t.Fatal(err) + } + return state + } + state := func() map[string]uint64 { return command(map[string]any{"action": "state"}) } + capturer := NewCapturer(executable) + request := CaptureRequest{Kind: WindowSource, Foreground: windows["window"], Generation: 1} + capture := func(request CaptureRequest) Capture { + t.Helper() + before := state() + result, err := capturer.Capture(ctx, request) + if err != nil { + t.Fatal(err) + } + after := state() + if before["focus"] != after["focus"] || before["windows"] != after["windows"] { + t.Fatalf("capture changed native focus/windows: %v -> %v", before, after) + } + return result + } + expectPixel := func(result Capture, point image.Point, want color.RGBA) { + t.Helper() + got := color.RGBAModel.Convert(result.Pixels.At(point.X, point.Y)).(color.RGBA) + if got != want { + t.Fatalf("pixel %v = %v, want %v", point, got, want) + } + } + // XGetImage uses physical pixels at both GDK scales. Provider conversion is + // intentionally a separate target-owner gate, not established by this test. + for _, scale := range []string{"1", "2"} { + t.Setenv("GDK_SCALE", scale) + result := capture(request) + if result.ScreenBounds != image.Rect(40, 30, 340, 230) || result.Pixels.Bounds() != image.Rect(0, 0, 300, 200) { + t.Fatalf("scale %s capture: %+v", scale, result.CaptureHeader) + } + expectPixel(result, image.Pt(0, 0), color.RGBA{0x33, 0x66, 0x99, 255}) + expectPixel(result, image.Pt(10, 15), color.RGBA{0xab, 0xcd, 0xef, 255}) + mapped, ok := result.Transform.ScreenRectangle(image.Rect(10, 15, 20, 25)) + if !ok || mapped != image.Rect(50, 45, 60, 55) { + t.Fatalf("pixel mapping: %v, %v", mapped, ok) + } + } + t.Setenv("GDK_SCALE", "1") + // A usable X11 source must not be rejected solely by the session label. + // This tests policy on Xvfb, not capture on a real Wayland compositor. + t.Setenv("XDG_SESSION_TYPE", "wayland") + expectPixel(capture(request), image.Pt(0, 0), color.RGBA{0x33, 0x66, 0x99, 255}) + t.Setenv("XDG_SESSION_TYPE", "x11") + childRequest := request + childRequest.Window = windows["child"] + child := capture(childRequest) + if child.ScreenBounds != image.Rect(50, 45, 130, 95) { + t.Fatalf("child capture: %+v", child.CaptureHeader) + } + expectPixel(child, image.Pt(0, 0), color.RGBA{0xab, 0xcd, 0xef, 255}) + objectRequest := request + objectRequest.Kind, objectRequest.Bounds = ObjectSource, image.Rect(-10, -10, 90, 100) + if result := capture(objectRequest); result.ScreenBounds != image.Rect(40, 30, 90, 100) { + t.Fatalf("object clip: %+v", result.CaptureHeader) + } + objectRequest.Bounds = image.Rect(-100, -100, -1, -1) + if _, err := capturer.Capture(ctx, objectRequest); !errors.Is(err, ErrSource) { + t.Fatalf("offscreen source: %v", err) + } + desktopRequest := request + desktopRequest.Kind = DesktopSource + desktop := capture(desktopRequest) + if desktop.ScreenBounds != desktop.RootBounds || desktop.SourceWindow != 0 { + t.Fatalf("desktop: %+v", desktop.CaptureHeader) + } + expectPixel(desktop, image.Pt(350, 30), color.RGBA{0x99, 0x22, 0x44, 255}) + if os.Getenv("CTHULHU_NATIVE_OCR") == "1" { + command(map[string]any{"action": "activate", "window": "painted"}) + paintedRequest := request + paintedRequest.Foreground = windows["painted"] + painted := capture(paintedRequest) + result, err := NewEngine().Recognize(ctx, painted.Pixels, Options{}) + if err != nil || result.Text != "OCR test window\nOpen Settings\nValue 42" { + t.Fatalf("captured X11 recognition: %q, %v", result.Text, err) + } + word, ok := result.WordAt(0) + mapped, mappedOK := painted.Transform.ScreenRectangle(word.Bounds) + if !ok || !mappedOK || !mapped.In(painted.ScreenBounds) { + t.Fatalf("recognized screen word mapping: %v, %v", mapped, mappedOK) + } + command(map[string]any{"action": "activate"}) + } + clipboardRequest := request + clipboardRequest.Kind = ClipboardSource + clipboard := capture(clipboardRequest) + if clipboard.Transform.HasScreenMapping || clipboard.Pixels.Bounds() != image.Rect(0, 0, 640, 220) { + t.Fatalf("clipboard mapping: %+v", clipboard.CaptureHeader) + } + command(map[string]any{"action": "clipboard-text"}) + if _, err := capturer.Capture(ctx, clipboardRequest); !errors.Is(err, ErrSource) { + t.Fatalf("text clipboard captured as image: %v", err) + } + // The earlier snapshot survives clipboard replacement and is recognized from + // those owned pixels rather than fetching the current clipboard again. + if os.Getenv("CTHULHU_NATIVE_OCR") == "1" { + result, err := NewEngine().Recognize(ctx, clipboard.Pixels, Options{}) + if err != nil || result.Text != "OCR test window\nOpen Settings\nValue 42" { + t.Fatalf("captured clipboard recognition: %q, %v", result.Text, err) + } + } + // Synchronize on the real selection request, switch foreground during the + // native transfer, and release pixels. The helper must reject that result. + command(map[string]any{"action": "clipboard-block"}) + completed := make(chan error, 1) + go func() { _, err := capturer.Capture(ctx, clipboardRequest); completed <- err }() + if !scanner.Scan() || scanner.Text() != `{"capture-request": 1}` { + t.Fatal("clipboard capture did not request its pixels") + } + command(map[string]any{"action": "activate", "window": "other"}) + command(map[string]any{"action": "clipboard-release"}) + if err := <-completed; !errors.Is(err, ErrSourceChanged) { + t.Fatalf("foreground change during capture: %v", err) + } + command(map[string]any{"action": "activate"}) + go func() { _, err := capturer.Capture(ctx, clipboardRequest); completed <- err }() + if !scanner.Scan() || scanner.Text() != `{"capture-request": 1}` { + t.Fatal("geometry-change capture did not start") + } + command(map[string]any{"action": "resize", "width": 310, "height": 210}) + command(map[string]any{"action": "clipboard-release"}) + if err := <-completed; !errors.Is(err, ErrSourceChanged) { + t.Fatalf("geometry change during capture: %v", err) + } + command(map[string]any{"action": "resize", "width": 300, "height": 200}) + // A provider that never replies must not hold the reader's worker forever. + go func() { _, err := capturer.Capture(ctx, clipboardRequest); completed <- err }() + if !scanner.Scan() || scanner.Text() != `{"capture-request": 1}` { + t.Fatal("blocked clipboard capture did not start") + } + if err := <-completed; !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("blocked native clipboard deadline: %v", err) + } + command(map[string]any{"action": "clipboard-text"}) + command(map[string]any{"action": "overlay"}) + expectPixel(capture(request), image.Pt(0, 0), color.RGBA{0xaa, 0x11, 0, 255}) + command(map[string]any{"action": "move", "x": -20, "y": -10}) + if result := capture(request); result.ScreenBounds != image.Rect(0, 0, 280, 190) || result.SourceBounds != image.Rect(-20, -10, 280, 190) { + t.Fatalf("negative window clip: %+v", result.CaptureHeader) + } + command(map[string]any{"action": "activate", "window": "other"}) + if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrSourceChanged) { + t.Fatalf("foreground mismatch: %v", err) + } + childRequest.Foreground = windows["other"] + childRequest.Window = windows["child"] + if _, err := capturer.Capture(ctx, childRequest); !errors.Is(err, ErrSource) { + t.Fatalf("unrelated window accepted: %v", err) + } + command(map[string]any{"action": "activate"}) + command(map[string]any{"action": "hide"}) + if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrSource) { + t.Fatalf("hidden window: %v", err) + } + command(map[string]any{"action": "destroy"}) + if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrSource) { + t.Fatalf("destroyed window: %v", err) + } + t.Setenv("DISPLAY", "") + if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrCapture) { + t.Fatalf("missing display: %v", err) + } +} diff --git a/internal/ocr/capture_test.go b/internal/ocr/capture_test.go new file mode 100644 index 0000000..2988337 --- /dev/null +++ b/internal/ocr/capture_test.go @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "hash/crc32" + "image" + "image/color" + "io" + "math" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func capture_request() CaptureRequest { + return CaptureRequest{Kind: ObjectSource, Bounds: image.Rect(-20, 10, 30, 50), Foreground: 123, Generation: 7} +} + +func capture_frame(t *testing.T, header CaptureHeader, pixels image.Image) []byte { + t.Helper() + var output bytes.Buffer + if err := WriteCaptureResponse(&output, header, pixels); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func TestCaptureRequestRejectsUnownedOrInvalidSources(t *testing.T) { + base := capture_request() + if err := base.Validate(); err != nil { + t.Fatal(err) + } + mutations := []func(*CaptureRequest){ + func(r *CaptureRequest) { r.Foreground = 0 }, + func(r *CaptureRequest) { r.Foreground = 1 << 32 }, + func(r *CaptureRequest) { r.Generation = 0 }, + func(r *CaptureRequest) { r.Kind = "unknown" }, + func(r *CaptureRequest) { r.Bounds = image.Rectangle{} }, + func(r *CaptureRequest) { r.Bounds.Min.X = math.MinInt32 }, + func(r *CaptureRequest) { r.Bounds = image.Rect(0, 0, MaxPixels+1, 1) }, + func(r *CaptureRequest) { r.Window = 42 }, + func(r *CaptureRequest) { r.Kind = WindowSource }, + } + for _, mutate := range mutations { + request := base + mutate(&request) + if !errors.Is(request.Validate(), ErrSource) { + t.Fatalf("invalid request accepted: %+v", request) + } + } + for _, kind := range []SourceKind{WindowSource, DesktopSource, ClipboardSource} { + request := base + request.Kind, request.Bounds = kind, image.Rectangle{} + if err := request.Validate(); err != nil { + t.Fatal(err) + } + } + data, _ := json.Marshal(base) + request, err := ReadCaptureRequest(bytes.NewReader(data)) + if err != nil || request != base { + t.Fatalf("request roundtrip: %+v, %v", request, err) + } + for _, invalid := range [][]byte{nil, []byte("{}"), append(append([]byte(nil), data...), data...), []byte(`{"Private":"generic"}`), bytes.Repeat([]byte{' '}, maxCaptureHeader+1)} { + if _, err := ReadCaptureRequest(bytes.NewReader(invalid)); err == nil { + t.Fatal("invalid private request accepted") + } + } +} + +func TestCaptureFrameClippingAndGeneration(t *testing.T) { + request := capture_request() + header := CaptureHeader{Request: request, SourceWindow: request.Foreground, + RootBounds: image.Rect(0, 0, 100, 100), SourceBounds: image.Rect(-10, 0, 80, 80), ScreenBounds: image.Rect(0, 10, 30, 50)} + pixels := image.NewRGBA(image.Rect(0, 0, 30, 40)) + pixels.Set(1, 2, color.RGBA{12, 34, 56, 255}) + frame := capture_frame(t, header, pixels) + result, err := decode_capture(frame, request) + if err != nil { + t.Fatal(err) + } + if color.RGBAModel.Convert(result.Pixels.At(1, 2)) != pixels.At(1, 2) { + t.Fatal("pixels changed") + } + if _, err := decode_capture(append(append([]byte(nil), frame...), 'x'), request); !errors.Is(err, ErrCapture) { + t.Fatalf("trailing payload accepted: %v", err) + } + mapped, ok := result.Transform.ScreenRectangle(image.Rect(1, 2, 3, 4)) + if !ok || mapped != image.Rect(1, 12, 3, 14) { + t.Fatalf("mapping: %v, %v", mapped, ok) + } + request.Generation++ + if _, err := decode_capture(frame, request); !errors.Is(err, ErrCapture) { + t.Fatalf("old generation accepted: %v", err) + } + request.Generation-- + for _, change := range []func(*CaptureHeader){ + func(h *CaptureHeader) { h.ScreenBounds.Min.X = -1 }, + func(h *CaptureHeader) { h.SourceBounds = image.Rect(50, 50, 80, 80) }, + func(h *CaptureHeader) { h.SourceWindow++ }, + func(h *CaptureHeader) { h.ScreenBounds.Max.X++ }, + } { + bad := header + change(&bad) + if _, err := decode_capture(capture_frame(t, bad, pixels), request); err == nil { + t.Fatalf("invalid mapping accepted: %+v", bad) + } + } +} + +func TestCaptureClipboardDoesNotMapToScreen(t *testing.T) { + request := CaptureRequest{Kind: ClipboardSource, Foreground: 123, Generation: 1} + header := CaptureHeader{Request: request} + pixels := image.NewRGBA(image.Rect(0, 0, 10, 10)) + result, err := decode_capture(capture_frame(t, header, pixels), request) + if err != nil || result.Transform.HasScreenMapping { + t.Fatalf("clipboard: %+v, %v", result, err) + } + header.ScreenBounds = image.Rect(0, 0, 10, 10) + if _, err := decode_capture(capture_frame(t, header, pixels), request); err == nil { + t.Fatal("clipboard gained coordinates") + } +} + +func TestCaptureProtocolErrorsAndPixelLimit(t *testing.T) { + request := capture_request() + for status, want := range map[string]error{"unavailable": ErrCapture, "source": ErrSource, "changed": ErrSourceChanged, "limit": ErrLimit, "Private diagnostics": ErrCapture} { + frame := capture_frame(t, CaptureHeader{Request: request, Status: status}, nil) + _, err := decode_capture(frame, request) + if !errors.Is(err, want) || strings.Contains(err.Error(), "Private") { + t.Fatalf("status %q: %v", status, err) + } + if _, err := decode_capture(append(frame, 'x'), request); !errors.Is(err, ErrCapture) { + t.Fatal("failure with payload accepted") + } + } + clipboard := CaptureRequest{Kind: ClipboardSource, Foreground: 123, Generation: 1} + frame := capture_frame(t, CaptureHeader{Request: clipboard}, image.NewRGBA(image.Rect(0, 0, 1, 1))) + start := bytes.IndexByte(frame, '\n') + 1 + // A valid CRC and malicious IHDR are rejected before allocating pixel data. + binary.BigEndian.PutUint32(frame[start+16:start+20], 10000) + binary.BigEndian.PutUint32(frame[start+20:start+24], 10000) + binary.BigEndian.PutUint32(frame[start+29:start+33], crc32.ChecksumIEEE(frame[start+12:start+29])) + if _, err := decode_capture(frame, clipboard); !errors.Is(err, ErrLimit) { + t.Fatalf("oversize PNG: %v", err) + } + for _, invalid := range [][]byte{nil, []byte("{}\nnot a png"), bytes.Repeat([]byte{'x'}, maxCaptureHeader+1)} { + if _, err := decode_capture(invalid, request); !errors.Is(err, ErrCapture) { + t.Fatalf("invalid response: %v", err) + } + } +} + +func TestCapturerMissingHelperAndCancellation(t *testing.T) { + request := capture_request() + if _, err := NewCapturer("/nonexistent-ocr-capture-helper").Capture(context.Background(), request); !errors.Is(err, ErrCapture) { + t.Fatalf("missing helper: %v", err) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + readyRead, readyWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer readyRead.Close() + defer readyWrite.Close() + capturer := NewCapturer(executable) + capturer.command = func(ctx context.Context, args ...string) *exec.Cmd { + command := exec.CommandContext(ctx, executable, "-test.run=^TestCaptureChildProcess$", "--", args[0]) + command.Env = append(os.Environ(), "CTHULHU_CAPTURE_TEST_CHILD=1") + command.ExtraFiles = []*os.File{readyWrite} + return command + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + go func() { _, err := capturer.Capture(ctx, request); completed <- err }() + _ = readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)) + data := make([]byte, 64) + count, err := readyRead.Read(data) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data[:count]))) + if err != nil { + t.Fatal(err) + } + waitContext, waitCancel := context.WithCancel(context.Background()) + waitCancel() + if _, err := capturer.Capture(waitContext, request); !errors.Is(err, context.Canceled) { + t.Fatalf("waiting cancellation: %v", err) + } + cancel() + select { + case err := <-completed: + if !errors.Is(err, context.Canceled) { + t.Fatalf("running cancellation: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("capture did not join cancelled helper") + } + if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("helper still alive or unreaped: %v", err) + } +} + +func TestCaptureChildProcess(t *testing.T) { + if os.Getenv("CTHULHU_CAPTURE_TEST_CHILD") != "1" { + return + } + if _, err := io.ReadAll(os.Stdin); err != nil { + os.Exit(20) + } + ready := os.NewFile(3, "ready") + _, _ = ready.WriteString(strconv.Itoa(os.Getpid()) + "\n") + _ = ready.Close() + time.Sleep(time.Hour) + os.Exit(0) +} diff --git a/internal/ocr/cursor.go b/internal/ocr/cursor.go new file mode 100644 index 0000000..45554d4 --- /dev/null +++ b/internal/ocr/cursor.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "sort" + "strings" +) + +type Unit uint8 + +const ( + Character Unit = iota + 1 + WordUnit + LineUnit + PageUnit + LineStart + LineEnd + DocumentStart + DocumentEnd +) + +// Cursor owns a virtual rune offset. It has no native accessible identity and +// never moves application focus. A session discards it on retirement. +type Cursor struct { + result Result + text []rune + lines []Span + lineTexts []string + offset, column int +} + +type Position struct { + Offset int + Text string + Line string + LineStart int + Cursor int // Rune offset within Line, including its end position. + Moved bool +} + +type ReadLine struct { + Offset int + Text string +} + +func NewCursor(result Result) *Cursor { + cursor := &Cursor{result: result, text: []rune(result.Text), lineTexts: strings.Split(result.Text, "\n")} + start := 0 + for index, char := range cursor.text { + if char == '\n' { + cursor.lines = append(cursor.lines, Span{start, index}) + start = index + 1 + } + } + cursor.lines = append(cursor.lines, Span{start, len(cursor.text)}) + return cursor +} + +func (cursor *Cursor) line_index() int { + return sort.Search(len(cursor.lines), func(index int) bool { return cursor.lines[index].End >= cursor.offset }) +} + +func (cursor *Cursor) Current(unit Unit) Position { + lineIndex := cursor.line_index() + line := cursor.lines[lineIndex] + position := Position{Offset: cursor.offset, Line: cursor.lineTexts[lineIndex], LineStart: line.Start, Cursor: cursor.offset - line.Start} + switch unit { + case Character, LineStart, LineEnd: + if cursor.offset < len(cursor.text) { + position.Text = string(cursor.text[cursor.offset]) + } + case WordUnit: + if word, ok := cursor.result.WordAt(cursor.offset); ok { + position.Text = word.Text + } + default: + position.Text = position.Line + } + return position +} + +func (cursor *Cursor) Move(unit Unit, step int) Position { + previous := cursor.offset + lineIndex := cursor.line_index() + line := cursor.lines[lineIndex] + switch unit { + case Character: + cursor.offset = max(0, min(len(cursor.text), cursor.offset+step)) + case WordUnit: + if step > 0 { + index := sort.Search(len(cursor.result.Words), func(index int) bool { return cursor.result.Words[index].Start > cursor.offset }) + if index < len(cursor.result.Words) { + cursor.offset = cursor.result.Words[index].Start + } + } else if step < 0 { + index := sort.Search(len(cursor.result.Words), func(index int) bool { return cursor.result.Words[index].Start >= cursor.offset }) - 1 + if index >= 0 { + cursor.offset = cursor.result.Words[index].Start + } + } + case LineUnit, PageUnit: + if unit == PageUnit { + step *= 20 + } + next := max(0, min(len(cursor.lines)-1, lineIndex+step)) + if next != lineIndex { + cursor.offset = cursor.lines[next].Start + min(cursor.column, cursor.lines[next].End-cursor.lines[next].Start) + } + case LineStart: + cursor.offset = line.Start + case LineEnd: + cursor.offset = line.End + case DocumentStart: + cursor.offset = 0 + case DocumentEnd: + cursor.offset = len(cursor.text) + } + if unit != LineUnit && unit != PageUnit { + cursor.column = cursor.offset - cursor.lines[cursor.line_index()].Start + } + position := cursor.Current(unit) + position.Moved = previous != cursor.offset + return position +} + +// Set moves only the virtual cursor. It rejects offsets outside the recognized +// text so a stale routing request cannot produce an invalid position. +func (cursor *Cursor) Set(offset int) (Position, bool) { + if offset < 0 || offset > len(cursor.text) { + return Position{}, false + } + cursor.offset = offset + cursor.column = offset - cursor.lines[cursor.line_index()].Start + return cursor.Current(Character), true +} + +func (cursor *Cursor) Length() int { return len(cursor.text) } +func (cursor *Cursor) Offset() int { return cursor.offset } +func (cursor *Cursor) Text() string { return cursor.result.Text } +func (cursor *Cursor) CurrentWord() (Word, bool) { return cursor.result.WordAt(cursor.offset) } + +func (cursor *Cursor) Slice(start, end int) (string, bool) { + if start < 0 || end < start || end > len(cursor.text) { + return "", false + } + return string(cursor.text[start:end]), true +} + +// LinesFrom returns whole lines after the virtual cursor. The first line +// begins at the cursor position; following lines retain recognition order. +func (cursor *Cursor) LinesFrom() []ReadLine { + index := cursor.line_index() + first := index + parts := make([]ReadLine, 0, len(cursor.lines)-index) + for ; index < len(cursor.lines); index++ { + line := cursor.lines[index] + start := line.Start + if index == first { + start = cursor.offset + } + if start < line.End { + parts = append(parts, ReadLine{Offset: start, Text: string(cursor.text[start:line.End])}) + } + } + return parts +} diff --git a/internal/ocr/cursor_continuous_test.go b/internal/ocr/cursor_continuous_test.go new file mode 100644 index 0000000..9b8f421 --- /dev/null +++ b/internal/ocr/cursor_continuous_test.go @@ -0,0 +1,23 @@ +package ocr + +import "testing" + +func TestCursorReadAllAndRoutingOffsets(t *testing.T) { + cursor := NewCursor(Result{Text: "é one\n\n猫 two"}) + if _, ok := cursor.Set(2); !ok { + t.Fatal("valid rune offset rejected") + } + lines := cursor.LinesFrom() + if len(lines) != 2 || lines[0].Text != "one" || lines[0].Offset != 2 || lines[1].Text != "猫 two" || lines[1].Offset != 7 { + t.Fatalf("lines from cursor: %+v", lines) + } + if _, ok := cursor.Set(-1); ok { + t.Fatal("negative route accepted") + } + if _, ok := cursor.Set(cursor.Length() + 1); ok { + t.Fatal("out of range route accepted") + } + if got, ok := cursor.Slice(0, 1); !ok || got != "é" { + t.Fatalf("rune selection: %q %t", got, ok) + } +} diff --git a/internal/ocr/cursor_test.go b/internal/ocr/cursor_test.go new file mode 100644 index 0000000..9463355 --- /dev/null +++ b/internal/ocr/cursor_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import "testing" + +func TestCursorUnicodeLinesAndEngineWordOrder(t *testing.T) { + result := Result{Text: "é e\u0301\n\nאב 42", Words: []Word{ + {Text: "é", Span: Span{0, 1}}, {Text: "e\u0301", Span: Span{2, 4}}, + {Text: "אב", Span: Span{6, 8}}, {Text: "42", Span: Span{9, 11}}, + }} + cursor := NewCursor(result) + if got := cursor.Current(LineUnit); got.Text != "é e\u0301" || got.Offset != 0 || got.Cursor != 0 { + t.Fatalf("initial: %+v", got) + } + if got := cursor.Move(WordUnit, 1); got.Text != "e\u0301" || got.Offset != 2 || got.Cursor != 2 { + t.Fatalf("combining word: %+v", got) + } + if got := cursor.Move(Character, 1); got.Text != "\u0301" || got.Offset != 3 { + t.Fatalf("combining character: %+v", got) + } + if got := cursor.Move(LineUnit, 1); got.Text != "" || got.Offset != 5 || got.Cursor != 0 { + t.Fatalf("blank paragraph: %+v", got) + } + if got := cursor.Move(LineUnit, 1); got.Text != "אב 42" || got.Offset != 9 || got.Cursor != 3 { + t.Fatalf("preserved column: %+v", got) + } + if got := cursor.Move(WordUnit, -1); got.Text != "אב" || got.Offset != 6 { + t.Fatalf("RTL engine order: %+v", got) + } + if got := cursor.Move(DocumentEnd, 0); got.Offset != 11 || got.Cursor != 5 || got.Text != "אב 42" { + t.Fatalf("end: %+v", got) + } + if got := cursor.Move(Character, 1); got.Moved || got.Offset != 11 { + t.Fatalf("past end: %+v", got) + } + cursor.Move(DocumentStart, 0) + if got := cursor.Move(Character, -1); got.Moved || got.Offset != 0 { + t.Fatalf("before start: %+v", got) + } +} + +func TestCursorLineEndsSeparatorsAndEmptyDocument(t *testing.T) { + cursor := NewCursor(Result{Text: "one\ntwo\n"}) + if got := cursor.Move(LineEnd, 0); got.Offset != 3 || got.Text != "\n" || got.Cursor != 3 { + t.Fatalf("line end: %+v", got) + } + if got := cursor.Move(Character, 1); got.Offset != 4 || got.Text != "t" || got.Line != "two" || got.Cursor != 0 { + t.Fatalf("cross separator: %+v", got) + } + if got := cursor.Move(DocumentEnd, 0); got.Offset != 8 || got.Line != "" || got.Cursor != 0 { + t.Fatalf("trailing blank: %+v", got) + } + if got := NewCursor(Result{}).Move(Character, 1); got.Offset != 0 || got.Text != "" || got.Moved { + t.Fatalf("empty: %+v", got) + } +} diff --git a/internal/ocr/engine.go b/internal/ocr/engine.go new file mode 100644 index 0000000..3e21172 --- /dev/null +++ b/internal/ocr/engine.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/png" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +const ( + discoveryTimeout = 2 * time.Second + recognitionTimeout = 15 * time.Second + maxCatalogBytes = 64 << 10 + maxImageBytes = 64 << 20 + maxStderrBytes = 16 << 10 +) + +type Layout int + +const ( + AutomaticPage Layout = 3 + SingleBlock Layout = 6 + SparseText Layout = 11 +) + +// Options is a per-job snapshot. Empty language chooses eng when installed, +// otherwise the first installed recognition language in sorted order. An +// explicit language expression is never replaced with a fallback. +type Options struct { + Language string + Layout Layout // Zero uses AutomaticPage; other unsupported values fail. + Scale int // Zero uses original size; 1 through 4 are explicit factors. + Grayscale bool + Invert bool + BlackWhite bool + Threshold int +} + +// Catalog contains recognition languages, excluding orientation-only osd data. +// Discovery is on demand, not part of reader startup. Each request rescans so +// installing/removing data does not require restarting the reader. +type Catalog struct { + Languages []string + DefaultLanguage string +} + +// Engine serializes its subprocesses. Call its blocking methods from a worker, +// never a native callback, GTK thread or the reader's event/input loop. A +// canceled call kills and reaps its process before releasing ownership. +type Engine struct { + gate chan struct{} + command func(context.Context, ...string) *exec.Cmd +} + +// NewEngine performs no dependency lookup, native initialization or I/O. +func NewEngine() *Engine { + return &Engine{gate: make(chan struct{}, 1), command: func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "tesseract", args...) + }} +} + +func (engine *Engine) acquire(ctx context.Context) error { + select { + case engine.gate <- struct{}{}: + if err := ctx.Err(); err != nil { + <-engine.gate + return err + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (engine *Engine) Discover(ctx context.Context) (Catalog, error) { + ctx, cancel := context.WithTimeout(ctx, discoveryTimeout) + defer cancel() + if err := engine.acquire(ctx); err != nil { + return Catalog{}, err + } + defer func() { <-engine.gate }() + return engine.discover(ctx) +} + +func (engine *Engine) discover(ctx context.Context) (Catalog, error) { + ctx, cancel := context.WithTimeout(ctx, discoveryTimeout) + defer cancel() + output, _, err := engine.run(ctx, nil, maxCatalogBytes, "--list-langs") + if err != nil { + if errors.Is(err, ErrRecognition) { + return Catalog{}, fmt.Errorf("%w: language discovery failed", ErrUnavailable) + } + return Catalog{}, err + } + return parse_catalog(string(output)) +} + +func parse_catalog(output string) (Catalog, error) { + lines := strings.Split(strings.TrimSpace(output), "\n") + header := strings.TrimSuffix(lines[0], "\r") + countStart := strings.LastIndex(header, "(") + if !strings.HasPrefix(header, "List of available languages in ") || !strings.HasSuffix(header, "):") || countStart < 0 { + return Catalog{}, fmt.Errorf("%w: invalid language catalog", ErrUnavailable) + } + count, err := strconv.Atoi(header[countStart+1 : len(header)-2]) + if err != nil || count < 0 || count > 512 || count != len(lines)-1 { + return Catalog{}, fmt.Errorf("%w: invalid language catalog", ErrUnavailable) + } + var catalog Catalog + seen := make(map[string]bool) + for _, line := range lines[1:] { + language := strings.TrimSuffix(line, "\r") + if !valid_language(language) || seen[language] { + return Catalog{}, fmt.Errorf("%w: invalid language catalog", ErrUnavailable) + } + seen[language] = true + if language != "osd" { + catalog.Languages = append(catalog.Languages, language) + } + } + if len(catalog.Languages) == 0 { + return Catalog{}, fmt.Errorf("%w: no recognition language data installed", ErrUnavailable) + } + sort.Strings(catalog.Languages) + catalog.DefaultLanguage = catalog.Languages[0] + if seen["eng"] { + catalog.DefaultLanguage = "eng" + } + return catalog, nil +} + +// valid_language permits upstream script/Latin-style identifiers, without +// accepting path traversal, options, whitespace or expression delimiters. +func valid_language(language string) bool { + if language == "" || len(language) > 128 { + return false + } + parts := strings.Split(language, "/") + if len(parts) > 2 || (len(parts) == 2 && parts[0] != "script") { + return false + } + for _, part := range parts { + if part == "" || part[0] == '-' { + return false + } + for _, char := range part { + if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' || char == '-') { + return false + } + } + } + return true +} + +func (catalog Catalog) resolve_language(expression string) (string, error) { + if expression == "" { + return catalog.DefaultLanguage, nil + } + if len(expression) > 1024 { + return "", fmt.Errorf("%w: invalid language selection", ErrUnavailable) + } + seen := make(map[string]bool) + for _, language := range strings.Split(expression, "+") { + if !valid_language(language) || language == "osd" || seen[language] { + return "", fmt.Errorf("%w: invalid language selection", ErrUnavailable) + } + seen[language] = true + index := sort.SearchStrings(catalog.Languages, language) + if index == len(catalog.Languages) || catalog.Languages[index] != language { + return "", fmt.Errorf("%w: selected language data is not installed", ErrUnavailable) + } + } + return expression, nil +} + +// Recognize encodes one owned image to PNG in memory and obtains text and +// coordinates from one TSV recognition invocation. The caller must not mutate +// pixels until this method returns. No images or text are logged or persisted. +func (engine *Engine) Recognize(ctx context.Context, pixels image.Image, options Options) (Result, error) { + ctx, cancel := context.WithTimeout(ctx, recognitionTimeout) + defer cancel() + if pixels == nil || !valid_dimensions(pixels.Bounds().Dx(), pixels.Bounds().Dy()) { + return Result{}, ErrInvalidImage + } + if options.Layout == 0 { + options.Layout = AutomaticPage + } + if options.Layout != AutomaticPage && options.Layout != SingleBlock && options.Layout != SparseText { + return Result{}, fmt.Errorf("%w: unsupported text layout", ErrRecognition) + } + if err := engine.acquire(ctx); err != nil { + return Result{}, err + } + defer func() { <-engine.gate }() + var err error + pixels, err = Preprocess(ctx, pixels, options) + if err != nil { + return Result{}, err + } + catalog, err := engine.discover(ctx) + if err != nil { + return Result{}, err + } + language, err := catalog.resolve_language(options.Language) + if err != nil { + return Result{}, err + } + encoded := &bounded_buffer{limit: maxImageBytes, ctx: ctx} + if err := png.Encode(encoded, pixels); err != nil { + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + if errors.Is(err, ErrLimit) { + return Result{}, ErrLimit + } + return Result{}, ErrInvalidImage + } + output, diagnostics, err := engine.run(ctx, encoded.Bytes(), maxTSVBytes, + "stdin", "stdout", "-l", language, "--psm", strconv.Itoa(int(options.Layout)), "tsv") + // Tesseract may exit successfully after loading only part of a multi-language + // expression. Never silently accept that degraded selection. + if strings.Contains(string(diagnostics), "Error opening data file") || + strings.Contains(string(diagnostics), "Failed loading language") || + strings.Contains(string(diagnostics), "couldn't load any languages") { + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + return Result{}, fmt.Errorf("%w: selected language data could not be loaded", ErrUnavailable) + } + if err != nil { + return Result{}, err + } + if err := ctx.Err(); err != nil { + return Result{}, err + } + result, err := ParseTSV(bytes.NewReader(output), pixels.Bounds().Dx(), pixels.Bounds().Dy()) + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + return result, err +} diff --git a/internal/ocr/engine_test.go b/internal/ocr/engine_test.go new file mode 100644 index 0000000..950983b --- /dev/null +++ b/internal/ocr/engine_test.go @@ -0,0 +1,431 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "image" + "image/png" + "io" + "os" + "os/exec" + "reflect" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestCatalogAndOrderedLanguages(t *testing.T) { + catalog, err := parse_catalog("List of available languages in \"/data\" (4):\nfra\nosd\nscript/Latin\neng\n") + if err != nil || catalog.DefaultLanguage != "eng" || + !reflect.DeepEqual(catalog.Languages, []string{"eng", "fra", "script/Latin"}) { + t.Fatalf("catalog: %+v, %v", catalog, err) + } + for _, expression := range []string{"", "fra+eng", "script/Latin"} { + got, err := catalog.resolve_language(expression) + want := expression + if want == "" { + want = "eng" + } + if err != nil || got != want { + t.Fatalf("language expression %q: %q, %v", expression, got, err) + } + } + for _, expression := range []string{"osd", "deu", "fra+missing", "eng+eng", "eng+", "+eng", "../eng", "-l eng", "eng;command", "fra\neng"} { + if _, err := catalog.resolve_language(expression); !errors.Is(err, ErrUnavailable) { + t.Fatalf("invalid/uninstalled expression %q accepted: %v", expression, err) + } + } + other, err := parse_catalog("List of available languages in \"/data\" (2):\nfra\ndeu\n") + if err != nil || other.DefaultLanguage != "deu" { + t.Fatalf("default without English: %+v, %v", other, err) + } +} + +func TestMissingAndMalformedCatalogs(t *testing.T) { + for _, output := range []string{ + "", "Private fixture text", + "List of available languages in \"/data\" (0):\n", + "List of available languages in \"/data\" (1):\nosd\n", + "List of available languages in \"/data\" (2):\neng\neng\n", + "List of available languages in \"/data\" (1):\neng\nfra\n", + "List of available languages in \"/data\" (1):\n../eng\n", + } { + _, err := parse_catalog(output) + if !errors.Is(err, ErrUnavailable) || !strings.HasPrefix(err.Error(), "OCR not available") || strings.Contains(err.Error(), "Private") { + t.Fatalf("unavailable message: %v", err) + } + } +} + +func test_engine(t *testing.T, mode string) *Engine { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + engine := NewEngine() + engine.command = func(ctx context.Context, args ...string) *exec.Cmd { + command := exec.CommandContext(ctx, executable, append([]string{"-test.run=^TestOCRChildProcess$", "--"}, args...)...) + command.Env = append(os.Environ(), "CTHULHU_OCR_TEST_PROCESS=1", "CTHULHU_OCR_TEST_MODE="+mode) + return command + } + return engine +} + +func TestEngineRecognizesOneTSVInvocation(t *testing.T) { + engine := test_engine(t, "success") + original := engine.command + var invocations [][]string + engine.command = func(ctx context.Context, args ...string) *exec.Cmd { + invocations = append(invocations, append([]string(nil), args...)) + return original(ctx, args...) + } + // A nonzero source origin is encoded as a normalized recognition image. + pixels := image.NewRGBA(image.Rect(-5, 10, 95, 110)) + result, err := engine.Recognize(context.Background(), pixels, Options{Language: "fra+eng", Layout: SingleBlock}) + if err != nil || result.Text != "é 中文" || result.Width != 100 || result.Height != 100 { + t.Fatalf("recognition: %+v, %v", result, err) + } + want := [][]string{{"--list-langs"}, {"stdin", "stdout", "-l", "fra+eng", "--psm", "6", "tsv"}} + if !reflect.DeepEqual(invocations, want) { + t.Fatalf("invocations: %v, want %v", invocations, want) + } + if result.Words[1].Span != (Span{2, 4}) { + t.Fatal("process result lost rune offsets") + } + if _, err := engine.Discover(context.Background()); err != nil { + t.Fatalf("engine did not release process ownership: %v", err) + } +} + +func TestEngineMissingExecutableAndUnavailableLanguage(t *testing.T) { + engine := NewEngine() // Constructing it never checks or loads OCR dependencies. + engine.command = func(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "/nonexistent-ocr-test-engine", args...) + } + if _, err := engine.Discover(context.Background()); !errors.Is(err, ErrUnavailable) { + t.Fatalf("missing executable: %v", err) + } + if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}); !errors.Is(err, ErrUnavailable) { + t.Fatalf("missing executable recognition: %v", err) + } + engine = test_engine(t, "success") + original := engine.command + var invocations int + engine.command = func(ctx context.Context, args ...string) *exec.Cmd { + invocations++ + return original(ctx, args...) + } + if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{Language: "missing"}); !errors.Is(err, ErrUnavailable) || invocations != 1 { + t.Fatalf("missing language was substituted or invoked: %v, %d", err, invocations) + } +} + +func TestDefaultEngineWithoutRuntimeDependencies(t *testing.T) { + // Use the real constructor and default executable lookup, with no helper + // injection. Even initialization and an attempted job need no installed OCR. + t.Setenv("PATH", t.TempDir()) + engine := NewEngine() + if _, err := engine.Discover(context.Background()); !errors.Is(err, ErrUnavailable) || err.Error() != "OCR not available" { + t.Fatalf("missing default backend: %v", err) + } + if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}); !errors.Is(err, ErrUnavailable) { + t.Fatalf("recognition without dependencies: %v", err) + } +} + +func TestEngineFailuresAndOutputBounds(t *testing.T) { + for _, test := range []struct { + mode string + want error + }{ + {"failure", ErrRecognition}, + {"invalid", ErrInvalidOutput}, + {"partial-language", ErrUnavailable}, + {"missing-data", ErrUnavailable}, + {"no-languages", ErrUnavailable}, + {"stdout-limit", ErrLimit}, + {"stderr-limit", ErrLimit}, + {"catalog-limit", ErrLimit}, + } { + t.Run(test.mode, func(t *testing.T) { + engine := test_engine(t, test.mode) + result, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}) + if !errors.Is(err, test.want) || result.Text != "" || len(result.Words) != 0 { + t.Fatalf("result: %+v, error: %v; want %v", result, err, test.want) + } + if strings.Contains(err.Error(), "Private") { + t.Fatal("native diagnostics leaked into error") + } + }) + } +} + +func TestEngineRejectsInvalidImageAndLayoutBeforeStartingChild(t *testing.T) { + engine := NewEngine() + engine.command = func(context.Context, ...string) *exec.Cmd { + t.Fatal("child invoked for invalid request") + return nil + } + for _, pixels := range []image.Image{nil, image.NewGray(image.Rectangle{}), oversized_image{}} { + if _, err := engine.Recognize(context.Background(), pixels, Options{}); !errors.Is(err, ErrInvalidImage) { + t.Fatalf("invalid image: %v", err) + } + } + if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{Layout: 4}); !errors.Is(err, ErrRecognition) { + t.Fatalf("invalid layout: %v", err) + } +} + +type oversized_image struct{ image.Image } + +func (oversized_image) Bounds() image.Rectangle { return image.Rect(0, 0, MaxPixels, 2) } + +func TestEngineCancellationReapsChildAndUnblocksWaitingRequest(t *testing.T) { + t.Run("cancel", func(t *testing.T) { check_child_cancellation(t, false) }) + t.Run("deadline", func(t *testing.T) { check_child_cancellation(t, true) }) +} + +func check_child_cancellation(t *testing.T, deadline bool) { + t.Helper() + engine := test_engine(t, "wait") + original := engine.command + readyRead, readyWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer readyRead.Close() + defer readyWrite.Close() + children := make(chan *exec.Cmd, 2) + engine.command = func(ctx context.Context, args ...string) *exec.Cmd { + command := original(ctx, args...) + if args[0] == "stdin" { + command.ExtraFiles = []*os.File{readyWrite} + children <- command + } + return command + } + ctx, cancel := context.WithCancel(context.Background()) + if deadline { + cancel() + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) + } + defer cancel() + done := make(chan error, 1) + go func() { + _, err := engine.Recognize(ctx, image.NewGray(image.Rect(0, 0, 100, 100)), Options{}) + done <- err + }() + if err := readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + if _, err := io.ReadFull(readyRead, make([]byte, 1)); err != nil { + t.Fatalf("child not ready: %v", err) + } + // A second call must not overlap recognition; it can cancel while waiting. + waitCtx, waitCancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer waitCancel() + if _, err := engine.Discover(waitCtx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("waiting call failed to cancel: %v", err) + } + want := error(context.Canceled) + if deadline { + want = context.DeadlineExceeded + } else { + cancel() + } + select { + case err := <-done: + if !errors.Is(err, want) { + t.Fatalf("cancel: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("canceled recognition did not join") + } + child := <-children + if child.ProcessState == nil { + t.Fatal("child Wait was not joined") + } + if _, err := syscall.Wait4(child.Process.Pid, nil, syscall.WNOHANG, nil); !errors.Is(err, syscall.ECHILD) { + t.Fatalf("child was not reaped: %v", err) + } + if _, err := engine.Discover(context.Background()); err != nil { + t.Fatalf("ownership not released after cancellation: %v", err) + } +} + +func TestEngineRetiresDescendantsHoldingPipes(t *testing.T) { + engine := test_engine(t, "orphan") + original := engine.command + readyRead, readyWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer readyRead.Close() + defer readyWrite.Close() + engine.command = func(ctx context.Context, args ...string) *exec.Cmd { + command := original(ctx, args...) + if args[0] == "stdin" { + command.ExtraFiles = []*os.File{readyWrite} + } + return command + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := engine.Recognize(ctx, image.NewGray(image.Rect(0, 0, 100, 100)), Options{}) + done <- err + }() + if err := readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + line, err := bufio.NewReader(readyRead).ReadString('\n') + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || pid <= 0 { + t.Fatalf("invalid descendant PID: %q, %v", line, err) + } + select { + case err := <-done: + if !errors.Is(err, ErrRecognition) { + t.Fatalf("inherited pipes did not fail within WaitDelay: %v", err) + } + case <-ctx.Done(): + t.Fatal("inherited pipes stalled process cleanup") + } + // SIGKILL delivery and adoption by init are asynchronous. A zombie has exited + // and holds no pipes; the parent engine cannot reap an adopted grandchild. + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + poll := time.NewTicker(10 * time.Millisecond) + defer poll.Stop() + for { + status, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if errors.Is(err, os.ErrNotExist) { + break + } + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(string(status[strings.LastIndexByte(string(status), ')')+1:])) + if len(fields) != 0 && fields[0] == "Z" { + break + } + select { + case <-deadline.C: + t.Fatal("descendant survived process-group cleanup") + case <-poll.C: + } + } +} + +func TestEngineExpiredDeadlineStartsNoChild(t *testing.T) { + engine := NewEngine() + engine.command = func(context.Context, ...string) *exec.Cmd { + t.Fatal("started child after expired deadline") + return nil + } + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + if _, err := engine.Discover(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expired discovery: %v", err) + } +} + +func TestBoundedBufferCannotBypassWriteLimit(t *testing.T) { + buffer := &bounded_buffer{limit: 5, ctx: context.Background()} + if _, err := io.Copy(buffer, strings.NewReader("0123456789")); !errors.Is(err, ErrLimit) || buffer.Len() > 5 { + t.Fatalf("writer limit bypassed: %d bytes, %v", buffer.Len(), err) + } +} + +// TestOCRChildProcess is an executable process fixture, not a mock of exec.Wait. +func TestOCRChildProcess(t *testing.T) { + if os.Getenv("CTHULHU_OCR_TEST_PROCESS") != "1" { + return + } + args := os.Args + for len(args) != 0 && args[0] != "--" { + args = args[1:] + } + if len(args) < 2 { + os.Exit(20) + } + args = args[1:] + mode := os.Getenv("CTHULHU_OCR_TEST_MODE") + if args[0] == "hold-pipes" { + ready := os.NewFile(3, "ready") + _, _ = fmt.Fprintln(ready, os.Getpid()) + _ = ready.Close() + time.Sleep(time.Hour) + os.Exit(0) + } + if args[0] == "--list-langs" { + switch mode { + case "no-languages": + fmt.Print("List of available languages in \"/data\" (1):\nosd\n") + case "catalog-limit": + fmt.Print(strings.Repeat("x", maxCatalogBytes+1)) + default: + fmt.Print("List of available languages in \"/data\" (3):\neng\nfra\nosd\n") + } + os.Exit(0) + } + if os.Getenv("OMP_THREAD_LIMIT") != "1" || os.Getenv("LC_ALL") != "C" { + os.Exit(21) + } + encoded, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20)) + if err != nil { + os.Exit(22) + } + pixels, err := png.Decode(bytes.NewReader(encoded)) + if err != nil || pixels.Bounds() != image.Rect(0, 0, 100, 100) { + os.Exit(23) + } + switch mode { + case "orphan": + child := exec.Command(os.Args[0], "-test.run=^TestOCRChildProcess$", "--", "hold-pipes") + child.Env = os.Environ() + child.Stdout, child.Stderr = os.Stdout, os.Stderr + child.ExtraFiles = []*os.File{os.NewFile(3, "ready")} + if err := child.Start(); err != nil { + os.Exit(24) + } + case "failure": + fmt.Fprint(os.Stderr, "Private fixture diagnostics") + os.Exit(1) + case "missing-data": + fmt.Fprint(os.Stderr, "Error opening data file /private/data/eng.traineddata\n") + os.Exit(1) + case "partial-language": + fmt.Fprint(os.Stderr, "Failed loading language 'fra'\n") + case "invalid": + fmt.Print("Private malformed TSV") + os.Exit(0) + case "stdout-limit": + fmt.Print(strings.Repeat("x", maxTSVBytes+1)) + os.Exit(0) + case "stderr-limit": + fmt.Fprint(os.Stderr, strings.Repeat("x", maxStderrBytes+1)) + os.Exit(0) + case "wait": + ready := os.NewFile(3, "ready") + _, _ = ready.Write([]byte{1}) + _ = ready.Close() + time.Sleep(time.Hour) + } + fmt.Print(tsvHeader + "\n5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t95\té\n5\t1\t1\t1\t1\t2\t20\t0\t10\t10\t90\t中文\n") + os.Exit(0) +} diff --git a/internal/ocr/model.go b/internal/ocr/model.go new file mode 100644 index 0000000..08961ea --- /dev/null +++ b/internal/ocr/model.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package ocr provides local recognition without build-time OCR dependencies. +// Captures, review sessions and native input remain separate owners. +package ocr + +import ( + "errors" + "image" + "math" + "sort" + "unicode" + "unicode/utf8" +) + +var ( + ErrUnavailable = errors.New("OCR not available") + ErrInvalidImage = errors.New("invalid OCR image") + ErrInvalidOutput = errors.New("invalid OCR output") + ErrLimit = errors.New("OCR limit exceeded") + ErrRecognition = errors.New("OCR recognition failed") +) + +const ( + MaxPixels = 16_000_000 + MaxTextBytes = 1 << 20 + maxTSVBytes = 8 << 20 + maxWords = 100_000 + maxRows = 200_000 +) + +// LineID preserves the full engine hierarchy, including line numbers which +// restart in each paragraph. Result order is engine order, not screen Y/X order. +type LineID struct { + Page, Block, Paragraph, Line int +} + +// Span is a half-open range of Unicode code points, never UTF-8 bytes. +// Combining marks retain their original offsets; text is not normalized. +type Span struct { + Start, End int +} + +type Word struct { + Text string + Confidence float64 + Bounds image.Rectangle + Span +} + +type Line struct { + ID LineID + Span + WordStart, WordEnd int +} + +// Result owns plain text and word rectangles in recognition-image pixels. +// An empty document is a successful recognition with no text. +type Result struct { + Text string + Width, Height int + Words []Word + Lines []Line +} + +// WordAt deliberately rejects separators and end-of-document positions. +// A later activation owner must validate source freshness before clicking. +func (result Result) WordAt(offset int) (Word, bool) { + index := sort.Search(len(result.Words), func(index int) bool { + return result.Words[index].End > offset + }) + if index == len(result.Words) || offset < result.Words[index].Start { + return Word{}, false + } + word := result.Words[index] + runeIndex := 0 + for _, char := range word.Text { + if runeIndex == offset-word.Start { + return word, !unicode.IsSpace(char) + } + runeIndex++ + } + return Word{}, false +} + +// ByteOffset explicitly bridges the reader's rune offsets to Go string slices. +// The offset at the end of the document is valid. +func (result Result) ByteOffset(offset int) (int, bool) { + if offset < 0 { + return 0, false + } + runeIndex := 0 + for byteIndex := range result.Text { + if runeIndex == offset { + return byteIndex, true + } + runeIndex++ + } + return len(result.Text), runeIndex == offset +} + +// Transform maps the content rectangle within the processed image to the +// clipped capture rectangle on screen. ImageContent accounts for padding; +// independent ratios account for rounded scaling in each axis. A clipboard +// image has HasScreenMapping=false and cannot supply screen coordinates. +// This mapping says nothing about source identity, freshness or monitor gaps. +type Transform struct { + ImageContent image.Rectangle + ScreenBounds image.Rectangle + HasScreenMapping bool +} + +// ScreenRectangle rounds outward and clips padding to the actual capture. +// Invalid, empty and entirely padded rectangles have no screen mapping. +func (transform Transform) ScreenRectangle(bounds image.Rectangle) (image.Rectangle, bool) { + content, screen := transform.ImageContent, transform.ScreenBounds + if !transform.HasScreenMapping || !valid_dimensions(content.Dx(), content.Dy()) || + content.Min.X < 0 || content.Min.Y < 0 || content.Max.X > MaxPixels || content.Max.Y > MaxPixels || + !valid_screen_rectangle(screen) { + return image.Rectangle{}, false + } + bounds = bounds.Intersect(content) + if bounds.Empty() { + return image.Rectangle{}, false + } + mapX := func(x int) float64 { + return float64(screen.Min.X) + float64(x-content.Min.X)*float64(screen.Dx())/float64(content.Dx()) + } + mapY := func(y int) float64 { + return float64(screen.Min.Y) + float64(y-content.Min.Y)*float64(screen.Dy())/float64(content.Dy()) + } + mapped := image.Rect(int(math.Floor(mapX(bounds.Min.X))), int(math.Floor(mapY(bounds.Min.Y))), + int(math.Ceil(mapX(bounds.Max.X))), int(math.Ceil(mapY(bounds.Max.Y)))) + mapped = mapped.Intersect(screen) + return mapped, !mapped.Empty() +} + +func valid_screen_rectangle(bounds image.Rectangle) bool { + return bounds.Min.X > math.MinInt32 && bounds.Min.Y > math.MinInt32 && + bounds.Max.X <= math.MaxInt32 && bounds.Max.Y <= math.MaxInt32 && + bounds.Max.X > bounds.Min.X && bounds.Max.Y > bounds.Min.Y && + int64(bounds.Max.X)-int64(bounds.Min.X) <= MaxPixels && + int64(bounds.Max.Y)-int64(bounds.Min.Y) <= MaxPixels +} + +func valid_dimensions(width, height int) bool { + return width > 0 && height > 0 && height <= MaxPixels && width <= MaxPixels/height +} + +func valid_word_bounds(bounds image.Rectangle, width, height int) bool { + return bounds.Min.X >= 0 && bounds.Min.Y >= 0 && !bounds.Empty() && + bounds.Max.X <= width && bounds.Max.Y <= height +} + +func rune_length(text string) int { return utf8.RuneCountInString(text) } diff --git a/internal/ocr/model_test.go b/internal/ocr/model_test.go new file mode 100644 index 0000000..a777ace --- /dev/null +++ b/internal/ocr/model_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "image" + "math" + "testing" +) + +func TestTransformScreenRectangle(t *testing.T) { + transform := Transform{ + ImageContent: image.Rect(10, 20, 310, 221), + ScreenBounds: image.Rect(-120, -50, -20, 50), + HasScreenMapping: true, + } + for _, test := range []struct { + name string + bounds, expected image.Rectangle + valid bool + }{ + {"full", transform.ImageContent, transform.ScreenBounds, true}, + {"independent axes", image.Rect(13, 22, 19, 26), image.Rect(-119, -50, -117, -47), true}, + {"partial padding", image.Rect(0, 0, 13, 22), image.Rect(-120, -50, -119, -49), true}, + {"only padding", image.Rect(0, 0, 10, 20), image.Rectangle{}, false}, + {"empty", image.Rectangle{}, image.Rectangle{}, false}, + {"beyond image", image.Rect(400, 400, 410, 410), image.Rectangle{}, false}, + } { + t.Run(test.name, func(t *testing.T) { + got, valid := transform.ScreenRectangle(test.bounds) + if got != test.expected || valid != test.valid { + t.Fatalf("mapped %v, %v; want %v, %v", got, valid, test.expected, test.valid) + } + }) + } + transform.HasScreenMapping = false + if _, valid := transform.ScreenRectangle(transform.ImageContent); valid { + t.Fatal("clipboard image acquired screen coordinates") + } + transform.HasScreenMapping = true + transform.ScreenBounds = image.Rect(math.MinInt32, 0, math.MinInt32+100, 100) + if _, valid := transform.ScreenRectangle(transform.ImageContent); valid { + t.Fatal("unknown native origin acquired coordinates") + } + transform.ScreenBounds = image.Rect(-math.MaxInt32, 0, math.MaxInt32, 100) + if _, valid := transform.ScreenRectangle(transform.ImageContent); valid { + t.Fatal("overflow-sized screen rectangle acquired coordinates") + } +} + +func TestRuneOffsetsAndWordHitTesting(t *testing.T) { + result := Result{ + Text: "é 中文\ne\u0301", + Words: []Word{ + {Text: "é", Span: Span{0, 1}}, + {Text: "中文", Span: Span{2, 4}}, + {Text: "e\u0301", Span: Span{5, 7}}, + }, + } + for offset, want := range []int{0, 2, 3, 6, 9, 10, 11, 13} { + got, valid := result.ByteOffset(offset) + if !valid || got != want { + t.Fatalf("byte offset %d = %d, %v; want %d", offset, got, valid, want) + } + } + for _, offset := range []int{-1, 1, 4, 7, 8} { + if word, valid := result.WordAt(offset); valid { + t.Fatalf("separator/end offset %d chose %q", offset, word.Text) + } + } + for _, offset := range []int{0, 2, 3, 5, 6} { + if _, valid := result.WordAt(offset); !valid { + t.Fatalf("word offset %d missing", offset) + } + } + for _, offset := range []int{-1, 8} { + if _, valid := result.ByteOffset(offset); valid { + t.Fatalf("invalid rune offset %d accepted", offset) + } + } + if offset, valid := (Result{}).ByteOffset(0); offset != 0 || !valid { + t.Fatal("empty document lost its end position") + } + phrase := Result{Text: "a b", Words: []Word{{Text: "a b", Span: Span{0, 3}}}} + if _, valid := phrase.WordAt(1); valid { + t.Fatal("whitespace inside an engine word acquired an activation target") + } +} diff --git a/internal/ocr/native_test.go b/internal/ocr/native_test.go new file mode 100644 index 0000000..43e547e --- /dev/null +++ b/internal/ocr/native_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "context" + "image" + "os" + "strings" + "testing" + "time" +) + +// This is a real installed-engine check, intentionally opt-in. Ordinary tests +// and the reader build must work with no tesseract executable or language data. +func TestNativeTesseract(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR") != "1" { + t.Skip("set CTHULHU_NATIVE_OCR=1 to test installed Tesseract") + } + engine := NewEngine() + catalog, err := engine.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + if language, err := catalog.resolve_language("eng"); err != nil || language != "eng" { + t.Fatal("the native painted-text fixture requires installed English data") + } + file, err := os.Open("testdata/painted-text.png") + if err != nil { + t.Fatal(err) + } + defer file.Close() + pixels, _, err := image.Decode(file) + if err != nil { + t.Fatal(err) + } + for _, layout := range []Layout{AutomaticPage, SingleBlock, SparseText} { + start := time.Now() + result, err := engine.Recognize(context.Background(), pixels, Options{Language: "eng", Layout: layout}) + if err != nil { + t.Fatal(err) + } + // Accept ordinary spacing/case variation between engine/model versions; + // this simple fixture is a pipe/coordinate proof, not an accuracy corpus. + actual := strings.ToLower(strings.Join(strings.Fields(result.Text), " ")) + if actual != "ocr test window open settings value 42" { + t.Fatalf("PSM %d did not read the known fixture: %q", layout, result.Text) + } + if len(result.Lines) != 3 || len(result.Words) != 7 { + t.Fatalf("PSM %d lost line/word structure: %+v", layout, result) + } + for _, word := range result.Words { + if !valid_word_bounds(word.Bounds, result.Width, result.Height) || + word.Bounds.Min.X < 20 || word.Bounds.Min.Y < 15 || word.Bounds.Max.Y > 185 { + t.Fatalf("PSM %d word outside painted area: %+v", layout, word) + } + } + t.Logf("PSM %d: %d words, %d lines, total %s (includes catalog and PNG encoding)", layout, len(result.Words), len(result.Lines), time.Since(start)) + } + scaled, err := engine.Recognize(context.Background(), pixels, Options{Language: "eng", Layout: SingleBlock, Scale: 2, Grayscale: true}) + if err != nil { + t.Fatal(err) + } + if scaled.Width != pixels.Bounds().Dx()*2 || scaled.Height != pixels.Bounds().Dy()*2 || len(scaled.Words) == 0 { + t.Fatalf("scaled recognition did not retain image coordinates: %dx%d, %d words", scaled.Width, scaled.Height, len(scaled.Words)) + } + for _, word := range scaled.Words { + if !valid_word_bounds(word.Bounds, scaled.Width, scaled.Height) { + t.Fatalf("invalid scaled word: %+v", word) + } + } +} diff --git a/internal/ocr/page_test.go b/internal/ocr/page_test.go new file mode 100644 index 0000000..377b59e --- /dev/null +++ b/internal/ocr/page_test.go @@ -0,0 +1,30 @@ +package ocr + +import ( + "strings" + "testing" +) + +func TestPageMovementRetainsColumnAndBounds(t *testing.T) { + lines := make([]string, 45) + for index := range lines { + lines[index] = "abcd" + } + cursor := NewCursor(Result{Text: strings.Join(lines, "\n")}) + cursor.Set(3) + if got := cursor.Move(PageUnit, 1); got.Line != "abcd" || got.Cursor != 3 || got.Offset != 103 { + t.Fatal(got) + } + if got := cursor.Move(PageUnit, 1); got.Offset != 203 { + t.Fatal(got) + } + if got := cursor.Move(PageUnit, 1); got.Offset != 223 { + t.Fatal(got) + } + if got := cursor.Move(PageUnit, 1); got.Moved { + t.Fatal(got) + } + if got := cursor.Move(PageUnit, -1); got.Offset != 123 { + t.Fatal(got) + } +} diff --git a/internal/ocr/preprocess.go b/internal/ocr/preprocess.go new file mode 100644 index 0000000..cdbd950 --- /dev/null +++ b/internal/ocr/preprocess.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "context" + "image" + "image/color" +) + +// Preprocess returns owned pixels in recognition coordinates. Scale uses nearest +// neighbor sampling so recognized word boxes map back through the capture transform. +func Preprocess(ctx context.Context, source image.Image, options Options) (image.Image, error) { + if source == nil || !valid_dimensions(source.Bounds().Dx(), source.Bounds().Dy()) { + return nil, ErrInvalidImage + } + scale := options.Scale + if scale == 0 { + scale = 1 + } + width, height := source.Bounds().Dx(), source.Bounds().Dy() + if scale < 1 || scale > 4 || height > MaxPixels/scale || !valid_dimensions(width*scale, height*scale) || + options.Threshold < 0 || options.Threshold > 255 { + return nil, ErrLimit + } + if scale == 1 && !options.Grayscale && !options.Invert && !options.BlackWhite { + return source, nil + } + processed := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale)) + for y := 0; y < height*scale; y++ { + if y%32 == 0 && ctx.Err() != nil { + return nil, ctx.Err() + } + for x := 0; x < width*scale; x++ { + r, g, b, _ := source.At(source.Bounds().Min.X+x/scale, source.Bounds().Min.Y+y/scale).RGBA() + red, green, blue := uint8(r>>8), uint8(g>>8), uint8(b>>8) + gray := uint8((299*uint32(red) + 587*uint32(green) + 114*uint32(blue) + 500) / 1000) + if options.Grayscale || options.BlackWhite { + red, green, blue = gray, gray, gray + } + if options.Invert { + red, green, blue = 255-red, 255-green, 255-blue + } + if options.BlackWhite { + value := uint8(0) + if int(red) >= options.Threshold { + value = 255 + } + red, green, blue = value, value, value + } + processed.SetRGBA(x, y, color.RGBA{R: red, G: green, B: blue, A: 255}) + } + } + return processed, ctx.Err() +} diff --git a/internal/ocr/preprocess_test.go b/internal/ocr/preprocess_test.go new file mode 100644 index 0000000..6faca37 --- /dev/null +++ b/internal/ocr/preprocess_test.go @@ -0,0 +1,47 @@ +package ocr + +import ( + "context" + "image" + "image/color" + "testing" +) + +func TestPreprocessScaleThresholdAndLimit(t *testing.T) { + source := image.NewRGBA(image.Rect(4, 5, 6, 6)) + source.SetRGBA(4, 5, color.RGBA{R: 20, G: 20, B: 20, A: 255}) + source.SetRGBA(5, 5, color.RGBA{R: 220, G: 220, B: 220, A: 255}) + processed, err := Preprocess(context.Background(), source, Options{Scale: 2, BlackWhite: true, Threshold: 200}) + if err != nil { + t.Fatal(err) + } + if processed.Bounds() != image.Rect(0, 0, 4, 2) { + t.Fatal(processed.Bounds()) + } + for x := 0; x < 4; x++ { + r, _, _, _ := processed.At(x, 0).RGBA() + want := uint32(0) + if x >= 2 { + want = 0xffff + } + if r != want { + t.Fatalf("pixel %d = %d", x, r) + } + } + if _, err := Preprocess(context.Background(), source, Options{Scale: 5}); err != ErrLimit { + t.Fatal(err) + } +} + +func TestPreprocessInversion(t *testing.T) { + source := image.NewRGBA(image.Rect(0, 0, 1, 1)) + source.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + processed, err := Preprocess(context.Background(), source, Options{Invert: true}) + if err != nil { + t.Fatal(err) + } + r, g, b, _ := processed.At(0, 0).RGBA() + if r != 245*257 || g != 235*257 || b != 225*257 { + t.Fatalf("inverted pixel %d %d %d", r, g, b) + } +} diff --git a/internal/ocr/process.go b/internal/ocr/process.go new file mode 100644 index 0000000..3965aee --- /dev/null +++ b/internal/ocr/process.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// bounded_buffer never retains more than limit bytes. Child-output overflow +// cancels its process immediately instead of leaving a stopped pipe reader. +type bounded_buffer struct { + data bytes.Buffer + limit int + ctx context.Context + cancel context.CancelFunc + overflow bool +} + +func (buffer *bounded_buffer) Write(data []byte) (int, error) { + if err := buffer.ctx.Err(); err != nil { + return 0, err + } + if len(data) > buffer.limit-buffer.Len() { + buffer.overflow = true + if buffer.cancel != nil { + buffer.cancel() + } + return 0, ErrLimit + } + return buffer.data.Write(data) +} + +func (buffer *bounded_buffer) Bytes() []byte { return buffer.data.Bytes() } +func (buffer *bounded_buffer) Len() int { return buffer.data.Len() } + +func (engine *Engine) run(parent context.Context, input []byte, limit int, args ...string) ([]byte, []byte, error) { + return run_process(parent, engine.command, input, limit, args...) +} + +func run_process(parent context.Context, factory func(context.Context, ...string) *exec.Cmd, input []byte, limit int, args ...string) ([]byte, []byte, error) { + ctx, cancel := context.WithCancel(parent) + defer cancel() + command := factory(ctx, args...) + // This reader targets Linux/Xorg. Own the whole process group, including + // descendants that might inherit pipes from a wrapper executable. + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + kill := func() error { + if command.Process == nil { + return os.ErrProcessDone + } + err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + command.Cancel = kill + command.WaitDelay = time.Second + command.Env = process_environment(command.Env) + command.Stdin = bytes.NewReader(input) + output := &bounded_buffer{limit: limit, ctx: ctx, cancel: cancel} + diagnostics := &bounded_buffer{limit: maxStderrBytes, ctx: ctx, cancel: cancel} + command.Stdout, command.Stderr = output, diagnostics + err := command.Run() + // WaitDelay closes inherited pipes after a bound, but os/exec only kills the + // direct child then. Retire any remaining group before admitting another job. + if command.Process != nil { + _ = kill() + } + if parent.Err() != nil { + return nil, nil, parent.Err() + } + if output.overflow || diagnostics.overflow { + return nil, nil, ErrLimit + } + if err != nil { + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + return nil, nil, ErrUnavailable + } + // Never forward native stderr: it can contain captured text or paths. + return nil, diagnostics.Bytes(), ErrRecognition + } + return output.Bytes(), diagnostics.Bytes(), nil +} + +func process_environment(environment []string) []string { + if environment == nil { + environment = os.Environ() + } + result := make([]string, 0, len(environment)+2) + for _, entry := range environment { + if !strings.HasPrefix(entry, "LC_ALL=") && !strings.HasPrefix(entry, "OMP_THREAD_LIMIT=") { + result = append(result, entry) + } + } + // Stable CLI diagnostics; avoid using every core for a desktop OCR request. + return append(result, "LC_ALL=C", "OMP_THREAD_LIMIT=1") +} diff --git a/internal/ocr/testdata/painted-text.png b/internal/ocr/testdata/painted-text.png new file mode 100644 index 0000000000000000000000000000000000000000..9b1c0f32ef5b7939aa8edc06b03a583e373be24d GIT binary patch literal 11544 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 maxRows || limited.N <= 0 { + return Result{}, ErrLimit + } + fields := strings.SplitN(scanner.Text(), "\t", 12) + if len(fields) != 12 { + return Result{}, invalid_row(rows) + } + var numbers [10]int + for index := range numbers { + number, err := strconv.ParseInt(fields[index], 10, 32) + if err != nil || number < 0 { + return Result{}, invalid_row(rows) + } + numbers[index] = int(number) + } + level := numbers[0] + if level < 1 || level > 5 || numbers[1] < 1 { + return Result{}, invalid_row(rows) + } + for index := 2; index <= 5; index++ { + if (index <= level && numbers[index] < 1) || (index > level && numbers[index] != 0) { + return Result{}, invalid_row(rows) + } + } + confidence, err := strconv.ParseFloat(fields[10], 64) + if err != nil || math.IsNaN(confidence) || math.IsInf(confidence, 0) || confidence < -1 || confidence > 100 { + return Result{}, invalid_row(rows) + } + // Subtraction avoids overflowing 32-bit additions on ARM builds. + left, top, boxWidth, boxHeight := numbers[6], numbers[7], numbers[8], numbers[9] + if left > width || top > height || boxWidth > width-left || boxHeight > height-top { + return Result{}, invalid_row(rows) + } + wordText := fields[11] + if !utf8.ValidString(wordText) || strings.ContainsFunc(wordText, unicode.IsControl) { + return Result{}, invalid_row(rows) + } + if level != 5 { + if wordText != "" || confidence != -1 { + return Result{}, invalid_row(rows) + } + continue + } + if strings.TrimSpace(wordText) == "" { + continue + } + bounds := image.Rect(left, top, left+boxWidth, top+boxHeight) + if !valid_word_bounds(bounds, width, height) || strings.TrimSpace(wordText) != wordText { + return Result{}, invalid_row(rows) + } + separatorBytes := 0 + if len(result.Words) != 0 { + separatorBytes = 1 + } + if len(result.Words) >= maxWords || text.Len()+len(wordText)+separatorBytes > MaxTextBytes { + return Result{}, ErrLimit + } + id := LineID{numbers[1], numbers[2], numbers[3], numbers[4]} + if len(result.Lines) == 0 || result.Lines[len(result.Lines)-1].ID != id { + if closedLines[id] { + return Result{}, invalid_row(rows) + } + if len(result.Lines) != 0 { + closedLines[result.Lines[len(result.Lines)-1].ID] = true + text.WriteByte('\n') + offset++ + } + result.Lines = append(result.Lines, Line{ID: id, Span: Span{Start: offset}, WordStart: len(result.Words)}) + } else { + text.WriteByte(' ') + offset++ + } + start := offset + text.WriteString(wordText) + offset += rune_length(wordText) + result.Words = append(result.Words, Word{wordText, confidence, bounds, Span{start, offset}}) + line := &result.Lines[len(result.Lines)-1] + line.End, line.WordEnd = offset, len(result.Words) + } + if limited.N <= 0 { + return Result{}, ErrLimit + } + if scanner.Err() != nil { + if errors.Is(scanner.Err(), bufio.ErrTooLong) { + return Result{}, ErrLimit + } + return Result{}, fmt.Errorf("%w: reading TSV", ErrInvalidOutput) + } + result.Text = text.String() + return result, nil +} + +func invalid_row(row int) error { + return fmt.Errorf("%w: TSV row %d", ErrInvalidOutput, row) +} diff --git a/internal/ocr/tsv_test.go b/internal/ocr/tsv_test.go new file mode 100644 index 0000000..04940d5 --- /dev/null +++ b/internal/ocr/tsv_test.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package ocr + +import ( + "errors" + "image" + "strings" + "testing" + "unicode/utf8" +) + +func TestParseTSVHierarchyUnicodeAndEngineOrder(t *testing.T) { + data := tsvHeader + "\n" + + "1\t1\t0\t0\t0\t0\t0\t0\t200\t100\t-1\t\n" + + "2\t1\t1\t0\t0\t0\t0\t0\t200\t100\t-1\t\n" + + "3\t1\t1\t1\t0\t0\t0\t0\t200\t100\t-1\t\n" + + "4\t1\t1\t1\t1\t0\t0\t0\t200\t100\t-1\t\n" + + "5\t1\t1\t1\t1\t1\t100\t50\t30\t20\t0\té\n" + + "5\t1\t1\t1\t1\t2\t10\t50\t30\t20\t-1\t中文\n" + + "5\t1\t1\t2\t1\t1\t10\t10\t30\t20\t95.25\te\u0301\n" + + "5\t2\t1\t1\t1\t1\t10\t10\t30\t20\t85\tNext" + result, err := ParseTSV(strings.NewReader(data), 200, 100) + if err != nil { + t.Fatal(err) + } + if result.Text != "é 中文\ne\u0301\nNext" || len(result.Lines) != 3 || len(result.Words) != 4 { + t.Fatalf("wrong assembly: %+v", result) + } + if result.Words[1].Span != (Span{2, 4}) || result.Words[2].Span != (Span{5, 7}) || + result.Words[1].Confidence != -1 || result.Words[0].Confidence != 0 || + result.Words[0].Bounds != image.Rect(100, 50, 130, 70) { + t.Fatalf("lost Unicode offsets, low confidence or coordinates: %+v", result.Words) + } + if result.Lines[1].ID != (LineID{1, 1, 2, 1}) || result.Lines[1].Span != (Span{5, 7}) || + result.Lines[1].WordStart != 2 || result.Lines[1].WordEnd != 3 { + t.Fatalf("lost paragraph/line ownership: %+v", result.Lines) + } +} + +func TestParseTSVEmptyAndQuotedWords(t *testing.T) { + for _, data := range []string{tsvHeader + "\n", tsvHeader + "\r\n1\t1\t0\t0\t0\t0\t0\t0\t100\t100\t-1\t\r\n"} { + result, err := ParseTSV(strings.NewReader(data), 100, 100) + if err != nil || result.Text != "" || len(result.Words) != 0 { + t.Fatalf("empty result: %+v, %v", result, err) + } + } + data := tsvHeader + "\n5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t99\t\"word\"\n" + result, err := ParseTSV(strings.NewReader(data), 100, 100) + if err != nil || result.Text != `"word"` { + t.Fatalf("TSV word treated as CSV quoting: %+v, %v", result, err) + } +} + +func TestParseTSVRejectsMalformedOutputAtomically(t *testing.T) { + row := "5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t90\tPrivate fixture text" + for _, bad := range []string{ + "missing fields", + strings.Replace(row, "5\t", "6\t", 1), + strings.Replace(row, "5\t1", "5\t0", 1), + strings.Replace(row, "0\t0\t10\t10", "-1\t0\t10\t10", 1), + strings.Replace(row, "0\t0\t10\t10", "95\t0\t10\t10", 1), + strings.Replace(row, "0\t0\t10\t10", "0\t0\t0\t10", 1), + strings.Replace(row, "0\t0\t10\t10", "0\t0\t2147483647\t10", 1), + strings.Replace(row, "90\t", "NaN\t", 1), + strings.Replace(row, "90\t", "+Inf\t", 1), + strings.Replace(row, "90\t", "101\t", 1), + strings.Replace(row, "90\t", "-2\t", 1), + row + "\x00", + row + "\xff", + row + "\textra field", + row + " ", + } { + result, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+row+"\n"+bad+"\n"), 100, 100) + if !errors.Is(err, ErrInvalidOutput) || result.Text != "" || len(result.Words) != 0 { + t.Fatalf("accepted malformed row or partial document: %+v, %v", result, err) + } + if strings.Contains(err.Error(), "Private") { + t.Fatal("recognized text leaked into error") + } + } + if _, err := ParseTSV(strings.NewReader("not a TSV header"), 100, 100); !errors.Is(err, ErrInvalidOutput) { + t.Fatal("invalid header accepted") + } + if _, err := ParseTSV(strings.NewReader(tsvHeader), MaxPixels, 2); !errors.Is(err, ErrInvalidImage) { + t.Fatal("pixel limit ignored") + } +} + +func TestParseTSVLimitsAndRevisitedLine(t *testing.T) { + if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+strings.Repeat("x", 64<<10)), 100, 100); !errors.Is(err, ErrLimit) { + t.Fatalf("unbounded row: %v", err) + } + row := "5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t90\tword\n" + secondLine := strings.Replace(row, "1\t1\t1\t1\t1\t", "1\t1\t1\t2\t1\t", 1) + if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+row+secondLine+row), 100, 100); !errors.Is(err, ErrInvalidOutput) { + t.Fatalf("revisited line accepted: %v", err) + } + largeWord := "5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t90\t" + strings.Repeat("x", 60<<10) + "\n" + if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+strings.Repeat(largeWord, 20)), 100, 100); !errors.Is(err, ErrLimit) { + t.Fatalf("text limit ignored: %v", err) + } + if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+strings.Repeat(row, maxWords+1)), 100, 100); !errors.Is(err, ErrLimit) { + t.Fatalf("word limit ignored: %v", err) + } +} + +func FuzzParseTSV(f *testing.F) { + f.Add(tsvHeader + "\n5\t1\t1\t1\t1\t1\t1\t1\t10\t10\t90\t中文\n") + f.Add("") + f.Fuzz(func(t *testing.T, data string) { + result, err := ParseTSV(strings.NewReader(data), 200, 100) + if err != nil { + if result.Text != "" || len(result.Words) != 0 { + t.Fatal("partial result escaped a parser failure") + } + return + } + if !utf8.ValidString(result.Text) || len(result.Text) > MaxTextBytes { + t.Fatal("invalid document text") + } + lastEnd := 0 + for _, word := range result.Words { + start, startOK := result.ByteOffset(word.Start) + end, endOK := result.ByteOffset(word.End) + if !startOK || !endOK || word.Start < lastEnd || word.End <= word.Start || + result.Text[start:end] != word.Text || !valid_word_bounds(word.Bounds, result.Width, result.Height) { + t.Fatal("invalid word map") + } + lastEnd = word.End + } + }) +} diff --git a/internal/ocrcapture/capture.go b/internal/ocrcapture/capture.go new file mode 100644 index 0000000..6901e4f --- /dev/null +++ b/internal/ocrcapture/capture.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package ocrcapture is used only by the short-lived, headless native helper. +// The reader's worker uses ocr.Capturer and never calls GTK/X11 capture in-process. +package ocrcapture + +/* +#cgo pkg-config: gtk+-3.0 x11 +#include "native.h" +*/ +import "C" + +import ( + "bytes" + "context" + "image" + "image/png" + "io" + "os" + "runtime" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/ocr" +) + +func Run(ctx context.Context, input io.Reader, output io.Writer) error { + request, err := ocr.ReadCaptureRequest(input) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + header := ocr.CaptureHeader{Request: request, Status: "unavailable"} + runtime.LockOSThread() + defer runtime.UnlockOSThread() + // This backend captures physical X11 pixels, including Xwayland when it + // provides them. Native display/source checks decide availability. + _ = os.Setenv("GDK_BACKEND", "x11") + _ = os.Setenv("NO_AT_BRIDGE", "1") + kind := map[ocr.SourceKind]int{ocr.ObjectSource: 1, ocr.WindowSource: 2, ocr.DesktopSource: 3, ocr.ClipboardSource: 4}[request.Kind] + native := C.cthulhu_ocr_capture(C.int(kind), C.ulong(request.Foreground), C.ulong(request.Window), + C.int(request.Bounds.Min.X), C.int(request.Bounds.Min.Y), C.int(request.Bounds.Max.X), C.int(request.Bounds.Max.Y)) + defer C.cthulhu_ocr_capture_free(&native) + if err := ctx.Err(); err != nil { + return err + } + if native.status != 0 { + header.Status = map[int]string{1: "unavailable", 2: "source", 3: "changed", 4: "limit"}[int(native.status)] + return ocr.WriteCaptureResponse(output, header, nil) + } + header.Status = "" + var pixels image.Image + if request.Kind == ocr.ClipboardSource { + data := C.GoBytes(unsafe.Pointer(native.clipboard), native.clipboardLength) + config, decodeErr := png.DecodeConfig(bytes.NewReader(data)) + if decodeErr != nil { + header.Status = "source" + } else if config.Width <= 0 || config.Height <= 0 || config.Height > ocr.MaxPixels || config.Width > ocr.MaxPixels/config.Height { + header.Status = "limit" + } else { + pixels, decodeErr = png.Decode(bytes.NewReader(data)) + if decodeErr != nil { + header.Status = "source" + } + } + } else { + header.SourceWindow = uint64(native.sourceWindow) + header.RootBounds = image.Rect(0, 0, int(native.rootWidth), int(native.rootHeight)) + header.SourceBounds = image.Rect(int(native.sourceX), int(native.sourceY), int(native.sourceX)+int(native.sourceWidth), int(native.sourceY)+int(native.sourceHeight)) + header.ScreenBounds = image.Rect(int(native.x), int(native.y), int(native.x)+int(native.width), int(native.y)+int(native.height)) + width, height := int(native.width), int(native.height) + stride := int(C.gdk_pixbuf_get_rowstride(native.pixels)) + data := C.GoBytes(unsafe.Pointer(C.gdk_pixbuf_get_pixels(native.pixels)), C.int((height-1)*stride+width*3)) + owned := image.NewRGBA(image.Rect(0, 0, width, height)) + for row := range height { + for column := range width { + source := row*stride + column*3 + target := row*owned.Stride + column*4 + copy(owned.Pix[target:target+3], data[source:source+3]) + owned.Pix[target+3] = 255 + } + } + pixels = owned + } + return ocr.WriteCaptureResponse(output, header, pixels) +} diff --git a/internal/ocrcapture/native.c b/internal/ocrcapture/native.c new file mode 100644 index 0000000..04b137f --- /dev/null +++ b/internal/ocrcapture/native.c @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "native.h" +#include +#include +#include +#include + +// Status codes: unavailable, source unavailable, source changed, limit. +static Window active_window(Display *display, Window root) { + Atom type; + int format; + unsigned long count, remaining; + unsigned char *data = NULL; + Window window = None; + if (XGetWindowProperty(display, root, + XInternAtom(display, "_NET_ACTIVE_WINDOW", False), 0, 1, False, + XA_WINDOW, &type, &format, &count, &remaining, &data) == Success && + data && type == XA_WINDOW && format == 32 && count == 1) + window = *(Window *)data; + if (data) XFree(data); + return window; +} + +static int is_descendant(Display *display, Window window, Window ancestor) { + for (int depth = 0; window && depth < 64; depth++) { + if (window == ancestor) return 1; + Window root, parent, *children = NULL; + unsigned int count; + int ok = XQueryTree(display, window, &root, &parent, &children, &count); + if (children) XFree(children); + if (!ok || parent == window) break; + window = parent; + } + return 0; +} + +static int window_bounds(Display *display, Window root, Window window, + int *x, int *y, int *width, int *height) { + XWindowAttributes attributes; + Window child; + if (!XGetWindowAttributes(display, window, &attributes) || + attributes.map_state != IsViewable || attributes.class != InputOutput || + !XTranslateCoordinates(display, window, root, 0, 0, x, y, &child)) return 0; + *width = attributes.width; + *height = attributes.height; + return *width > 0 && *height > 0; +} + +static guchar component(unsigned long pixel, unsigned long mask) { + if (!mask) return 0; + unsigned long value = pixel & mask; + while (!(mask & 1)) { mask >>= 1; value >>= 1; } + return (guchar)(((uint64_t)value * 255 + mask / 2) / mask); +} + +OCRCapture cthulhu_ocr_capture(int kind, unsigned long foreground, + unsigned long window, int x, int y, int right, int bottom) { + OCRCapture result = {.status = 1}; + if (!gtk_init_check(NULL, NULL)) return result; + GdkDisplay *gdkDisplay = gdk_display_get_default(); + if (!GDK_IS_X11_DISPLAY(gdkDisplay)) return result; + Display *display = gdk_x11_display_get_xdisplay(gdkDisplay); + Window root = DefaultRootWindow(display); + gdk_x11_display_error_trap_push(gdkDisplay); + result.status = 2; + if (active_window(display, root) != foreground) { result.status = 3; goto done; } + Window source = window ? window : foreground; + int sourceX, sourceY, sourceWidth, sourceHeight; + if (!is_descendant(display, source, foreground) || !window_bounds(display, root, + source, &sourceX, &sourceY, &sourceWidth, &sourceHeight)) goto done; + + if (kind == 4) { + // Accept PNG image data only. Never fetch text, URIs or private formats. + GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + GtkSelectionData *selection = gtk_clipboard_wait_for_contents(clipboard, + gdk_atom_intern_static_string("image/png")); + if (!selection) goto done; + int length = gtk_selection_data_get_length(selection); + if (length > 64 * 1024 * 1024) result.status = 4; + else if (length > 0 && gtk_selection_data_get_format(selection) == 8) { + result.clipboard = g_memdup2(gtk_selection_data_get_data(selection), length); + result.clipboardLength = length; + result.status = 0; + } + gtk_selection_data_free(selection); + } else { + XWindowAttributes rootAttributes; + if (!XGetWindowAttributes(display, root, &rootAttributes)) goto done; + // TrueColor pixels are physical root pixels even with GDK_SCALE=2. + // Indexed/direct-color visuals would require a different color contract. + if (rootAttributes.visual->class != TrueColor) { result.status = 1; goto done; } + result.rootWidth = rootAttributes.width; + result.rootHeight = rootAttributes.height; + result.sourceWindow = kind == 3 ? 0 : source; + result.sourceX = kind == 3 ? 0 : sourceX; + result.sourceY = kind == 3 ? 0 : sourceY; + result.sourceWidth = kind == 3 ? result.rootWidth : sourceWidth; + result.sourceHeight = kind == 3 ? result.rootHeight : sourceHeight; + int64_t left = result.sourceX, top = result.sourceY; + int64_t endX = left + result.sourceWidth, endY = top + result.sourceHeight; + if (kind == 1) { + left = MAX(left, x); top = MAX(top, y); + endX = MIN(endX, right); endY = MIN(endY, bottom); + } + left = MAX(left, 0); top = MAX(top, 0); + endX = MIN(endX, result.rootWidth); endY = MIN(endY, result.rootHeight); + if (endX <= left || endY <= top) goto done; + int64_t width = endX - left, height = endY - top; + if (height > 16000000 || width > 16000000 / height) { result.status = 4; goto done; } + result.x = left; result.y = top; result.width = width; result.height = height; + XImage *pixels = XGetImage(display, root, result.x, result.y, + result.width, result.height, AllPlanes, ZPixmap); + if (!pixels) { result.status = 1; goto done; } + result.pixels = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, result.width, result.height); + if (!result.pixels) { XDestroyImage(pixels); result.status = 1; goto done; } + guchar *rgb = gdk_pixbuf_get_pixels(result.pixels); + int stride = gdk_pixbuf_get_rowstride(result.pixels); + for (int row = 0; row < result.height; row++) { + for (int column = 0; column < result.width; column++) { + unsigned long pixel = XGetPixel(pixels, column, row); + guchar *target = rgb + row * stride + column * 3; + target[0] = component(pixel, rootAttributes.visual->red_mask); + target[1] = component(pixel, rootAttributes.visual->green_mask); + target[2] = component(pixel, rootAttributes.visual->blue_mask); + } + } + XDestroyImage(pixels); + result.status = 0; + } + // Reject movement, destruction or a foreground switch during pixel/clipboard + // transfer. Later activation must still recapture/revalidate independently. + if (result.status == 0) { + int nextX, nextY, nextWidth, nextHeight; + if (active_window(display, root) != foreground || + !window_bounds(display, root, source, &nextX, &nextY, &nextWidth, &nextHeight) || + nextX != sourceX || nextY != sourceY || + nextWidth != sourceWidth || nextHeight != sourceHeight) result.status = 3; + } +done: + XSync(display, False); + if (gdk_x11_display_error_trap_pop(gdkDisplay)) result.status = 2; + if (result.status) cthulhu_ocr_capture_free(&result); + return result; +} + +void cthulhu_ocr_capture_free(OCRCapture *capture) { + if (capture->pixels) g_object_unref(capture->pixels); + g_free(capture->clipboard); + capture->pixels = NULL; + capture->clipboard = NULL; +} diff --git a/internal/ocrcapture/native.h b/internal/ocrcapture/native.h new file mode 100644 index 0000000..a7097e1 --- /dev/null +++ b/internal/ocrcapture/native.h @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include + +typedef struct { + int status; + unsigned long sourceWindow; + int rootWidth, rootHeight; + int sourceX, sourceY, sourceWidth, sourceHeight; + int x, y, width, height; + GdkPixbuf *pixels; + guchar *clipboard; + int clipboardLength; +} OCRCapture; + +OCRCapture cthulhu_ocr_capture(int kind, unsigned long foreground, + unsigned long window, int x, int y, int right, int bottom); +void cthulhu_ocr_capture_free(OCRCapture *capture); diff --git a/internal/ocrconfirm/confirm.c b/internal/ocrconfirm/confirm.c new file mode 100644 index 0000000..b7705c8 --- /dev/null +++ b/internal/ocrconfirm/confirm.c @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +int cthulhu_ocr_confirm(const char *word) { + if (!gtk_init_check(NULL, NULL)) return 0; + GtkWidget *dialog = gtk_dialog_new_with_buttons("Confirm OCR click", NULL, + GTK_DIALOG_MODAL, "_Cancel", GTK_RESPONSE_CANCEL, + "_Activate", GTK_RESPONSE_OK, NULL); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL); + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + gchar *message = g_strdup_printf("Activate recognized word “%s” at its screen position?", word); + GtkWidget *label = gtk_label_new(message); + g_free(message); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_container_add(GTK_CONTAINER(content), label); + gtk_widget_show_all(dialog); + int response = gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + while (gtk_events_pending()) gtk_main_iteration(); + return response == GTK_RESPONSE_OK; +} diff --git a/internal/ocrconfirm/confirm.go b/internal/ocrconfirm/confirm.go new file mode 100644 index 0000000..de21392 --- /dev/null +++ b/internal/ocrconfirm/confirm.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package ocrconfirm runs only in the short-lived confirmation helper. +package ocrconfirm + +/* +#cgo pkg-config: gtk+-3.0 +#include +int cthulhu_ocr_confirm(const char *word); +*/ +import "C" + +import ( + "bytes" + "io" + "unicode/utf8" + "unsafe" +) + +func Run(input io.Reader, output io.Writer) error { + data, err := io.ReadAll(io.LimitReader(input, 4097)) + if err != nil || len(data) == 0 || len(data) > 4096 || !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 { + return io.ErrUnexpectedEOF + } + word := C.CString(string(data)) + defer C.free(unsafe.Pointer(word)) + if C.cthulhu_ocr_confirm(word) == 1 { + _, err = io.WriteString(output, "yes\n") + } else { + _, err = io.WriteString(output, "cancel\n") + } + return err +} diff --git a/internal/parity/parity.go b/internal/parity/parity.go new file mode 100644 index 0000000..04376bf --- /dev/null +++ b/internal/parity/parity.go @@ -0,0 +1,177 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package parity + +import ( + "encoding/csv" + "errors" + "fmt" + "io" +) + +var ErrSchema = errors.New("parity ledger schema") + +const ( + StatusPending = "pending" + StatusInProgress = "in-progress" + StatusImplemented = "implemented" + StatusAuditRequired = "audit-required" + StatusAcceptedOmission = "accepted-omission" +) + +var allowedStatuses = map[string]struct{}{ + StatusPending: {}, + StatusInProgress: {}, + StatusImplemented: {}, + StatusAuditRequired: {}, + StatusAcceptedOmission: {}, +} + +var columns = []string{ + "id", + "python_source", + "subsystem", + "behavior", + "owner", + "go_destination", + "status", + "automated_evidence", + "x11_evidence", + "wayland_evidence", + "speech_evidence", + "braille_evidence", + "notes", +} + +type Record struct { + ID string + PythonSource string + Subsystem string + Behavior string + Owner string + GoDestination string + Status string + AutomatedEvidence string + X11Evidence string + WaylandEvidence string + SpeechEvidence string + BrailleEvidence string + Notes string +} + +type ParseError struct { + Row int + Err error +} + +func (parseError *ParseError) Error() string { + return fmt.Sprintf("row %d: %v", parseError.Row, parseError.Err) +} + +func (parseError *ParseError) Unwrap() error { + return parseError.Err +} + +func Load(reader io.Reader) ([]Record, error) { + csvReader := csv.NewReader(reader) + csvReader.FieldsPerRecord = len(columns) + csvReader.TrimLeadingSpace = true + + header, err := csvReader.Read() + if err != nil { + return nil, &ParseError{Row: 1, Err: fmt.Errorf("%w: read header: %w", ErrSchema, err)} + } + for index, column := range header { + if column != columns[index] { + return nil, &ParseError{ + Row: 1, + Err: fmt.Errorf("%w: column %d is %q, want %q", ErrSchema, index+1, column, columns[index]), + } + } + } + + ids := make(map[string]struct{}) + var records []Record + for row := 2; ; row++ { + fields, readErr := csvReader.Read() + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return nil, &ParseError{Row: row, Err: fmt.Errorf("read record: %w", readErr)} + } + record := Record{ + ID: fields[0], + PythonSource: fields[1], + Subsystem: fields[2], + Behavior: fields[3], + Owner: fields[4], + GoDestination: fields[5], + Status: fields[6], + AutomatedEvidence: fields[7], + X11Evidence: fields[8], + WaylandEvidence: fields[9], + SpeechEvidence: fields[10], + BrailleEvidence: fields[11], + Notes: fields[12], + } + if err := validate(record, row, ids); err != nil { + return nil, err + } + records = append(records, record) + } + return records, nil +} + +func validate(record Record, row int, ids map[string]struct{}) error { + for field, value := range map[string]string{ + "id": record.ID, + "python_source": record.PythonSource, + "subsystem": record.Subsystem, + "behavior": record.Behavior, + "status": record.Status, + } { + if value == "" { + return &ParseError{Row: row, Err: fmt.Errorf("%w: %s is empty", ErrSchema, field)} + } + } + if _, exists := allowedStatuses[record.Status]; !exists { + return &ParseError{ + Row: row, + Err: fmt.Errorf("%w: invalid status %q", ErrSchema, record.Status), + } + } + if _, exists := ids[record.ID]; exists { + return &ParseError{ + Row: row, + Err: fmt.Errorf("%w: duplicate id %q", ErrSchema, record.ID), + } + } + if record.GoDestination == "" && record.Status != StatusAcceptedOmission { + return &ParseError{ + Row: row, + Err: fmt.Errorf("%w: go_destination is empty for status %q", ErrSchema, record.Status), + } + } + ids[record.ID] = struct{}{} + return nil +} + +func FormatStatuses() string { + return "pending, in-progress, implemented, audit-required, accepted-omission" +} diff --git a/internal/parity/parity_test.go b/internal/parity/parity_test.go new file mode 100644 index 0000000..f911ee1 --- /dev/null +++ b/internal/parity/parity_test.go @@ -0,0 +1,88 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package parity + +import ( + "errors" + "strings" + "testing" +) + +func TestLoadValidLedger(t *testing.T) { + ledger := strings.Join([]string{ + strings.Join(columns, ","), + "core-startup,src/cthulhu/cthulhu.py,core,start the screen reader,Storm Dragon,internal/app,pending,,,,,,initial inventory", + "web-navigation,src/cthulhu/scripts/web/script.py,web,structural navigation,Storm Dragon,internal/script,pending,,,,,,initial inventory", + }, "\n") + + records, err := Load(strings.NewReader(ledger)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(records) != 2 { + t.Fatalf("record count = %d, want 2", len(records)) + } +} + +func TestLoadRejectsInvalidRecords(t *testing.T) { + tests := []struct { + name string + ledger string + wantErr error + }{ + { + name: "missing header", + ledger: "id\n", + wantErr: ErrSchema, + }, + { + name: "invalid status", + ledger: strings.Join([]string{ + strings.Join(columns, ","), + "one,source.py,core,behavior,owner,destination,unknown,,,,,,note", + }, "\n"), + wantErr: ErrSchema, + }, + { + name: "duplicate id", + ledger: strings.Join([]string{ + strings.Join(columns, ","), + "one,source.py,core,behavior,owner,destination,pending,,,,,,note", + "one,source.py,core,behavior,owner,destination,pending,,,,,,note", + }, "\n"), + wantErr: ErrSchema, + }, + { + name: "missing destination", + ledger: strings.Join([]string{ + strings.Join(columns, ","), + "one,source.py,core,behavior,owner,,pending,,,,,,note", + }, "\n"), + wantErr: ErrSchema, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := Load(strings.NewReader(test.ledger)) + if !errors.Is(err, test.wantErr) { + t.Fatalf("Load error = %v, want %v", err, test.wantErr) + } + }) + } +} diff --git a/internal/platform/session/session.go b/internal/platform/session/session.go new file mode 100644 index 0000000..c7f56e4 --- /dev/null +++ b/internal/platform/session/session.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package session keeps desktop session policy independent of native backends. +package session + +import ( + "os" + "strings" +) + +// Wayland respects an explicit session type; WAYLAND_DISPLAY is a fallback for +// launchers that do not set XDG_SESSION_TYPE. +func Wayland() bool { + kind := strings.ToLower(strings.TrimSpace(os.Getenv("XDG_SESSION_TYPE"))) + if kind != "" { + return kind == "wayland" + } + return strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) != "" +} + +// GTKEnvironment preserves Xorg focus correlation in explicit X11 sessions. +// Elsewhere GTK inherits the user's backend choice and selects its own default. +func GTKEnvironment() []string { + environment := os.Environ() + if strings.EqualFold(strings.TrimSpace(os.Getenv("XDG_SESSION_TYPE")), "x11") { + environment = append(environment, "GDK_BACKEND=x11") + } + return environment +} diff --git a/internal/platform/session/session_test.go b/internal/platform/session/session_test.go new file mode 100644 index 0000000..3601b1c --- /dev/null +++ b/internal/platform/session/session_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package session + +import ( + "strings" + "testing" +) + +func TestDesktopEnvironment(t *testing.T) { + for _, test := range []struct { + name, kind, display, backend, wantBackend string + wayland bool + }{ + {"Xorg", "x11", "", "wayland", "x11", false}, + {"Xorg with inherited Wayland display", "x11", "wayland-0", "", "x11", false}, + {"Wayland", "wayland", "wayland-0", "", "", true}, + {"Wayland user backend", "wayland", "wayland-0", "x11", "x11", true}, + {"normalized session", " Wayland ", "", "wayland,x11", "wayland,x11", true}, + {"Wayland display fallback", "", "wayland-0", "", "", true}, + {"unspecified session", "", "", "wayland,x11", "wayland,x11", false}, + {"explicit other session", "tty", "wayland-0", "", "", false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("XDG_SESSION_TYPE", test.kind) + t.Setenv("WAYLAND_DISPLAY", test.display) + t.Setenv("GDK_BACKEND", test.backend) + t.Setenv("CTHULHU_TEST_INHERITED", "preserved") + if got := Wayland(); got != test.wayland { + t.Fatalf("Wayland = %v, want %v", got, test.wayland) + } + values := make(map[string]string) + for _, entry := range GTKEnvironment() { + key, value, _ := strings.Cut(entry, "=") + values[key] = value + } + if values["GDK_BACKEND"] != test.wantBackend || values["CTHULHU_TEST_INHERITED"] != "preserved" { + t.Fatalf("GTK backend = %q, inherited value = %q", values["GDK_BACKEND"], values["CTHULHU_TEST_INHERITED"]) + } + }) + } +} diff --git a/internal/platform/xorg/applications.go b/internal/platform/xorg/applications.go new file mode 100644 index 0000000..e8b481f --- /dev/null +++ b/internal/platform/xorg/applications.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#include "window_title.h" +*/ +import "C" + +import "unsafe" + +type ApplicationWindow struct { + Application string + PID int + Wine bool +} + +// ApplicationWindows runs on the native owner thread, like ReadTitle. +func (reader *ForegroundReader) ApplicationWindows() []ApplicationWindow { + if reader == nil { + return nil + } + var count C.int + native := C.cthulhu_list_application_windows(reader.display, &count) + defer C.cthulhu_free_application_windows(native, count) + var windows []ApplicationWindow + for _, window := range unsafe.Slice(native, int(count)) { + windows = append(windows, ApplicationWindow{C.GoString(window.application), int(window.pid), window.wine != 0}) + } + return windows +} diff --git a/internal/platform/xorg/clipboard.go b/internal/platform/xorg/clipboard.go new file mode 100644 index 0000000..b798656 --- /dev/null +++ b/internal/platform/xorg/clipboard.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#cgo pkg-config: x11 xfixes +#include +#include +#include + +static int watch_clipboard(Display *display, int *event_base) { + int error_base; + if (!XFixesQueryExtension(display, event_base, &error_base)) return 0; + Atom clipboard = XInternAtom(display, "CLIPBOARD", False); + XFixesSelectSelectionInput(display, DefaultRootWindow(display), clipboard, + XFixesSetSelectionOwnerNotifyMask); + XSync(display, False); + return 1; +} + +static int next_clipboard_change(Display *display, int event_base) { + while (XPending(display)) { + XEvent event; + XNextEvent(display, &event); + if (event.type == event_base + XFixesSelectionNotify) { + XFixesSelectionNotifyEvent *change = (XFixesSelectionNotifyEvent *)&event; + if (change->owner != None) return 1; + } + } + return 0; +} +*/ +import "C" + +import ( + "context" + "errors" + "runtime" + "sync" + "time" +) + +// ClipboardMonitor reports successful X11 clipboard ownership changes. It +// never reads clipboard contents or blocks the keyboard listener. +type ClipboardMonitor struct { + changes chan struct{} + cancel context.CancelFunc + done chan struct{} + mutex sync.Mutex +} + +func NewClipboardMonitor() *ClipboardMonitor { + return &ClipboardMonitor{changes: make(chan struct{}, 16)} +} + +func (monitor *ClipboardMonitor) Changes() <-chan struct{} { return monitor.changes } + +func (monitor *ClipboardMonitor) Start(ctx context.Context) error { + monitor.mutex.Lock() + defer monitor.mutex.Unlock() + if monitor.done != nil { + return errors.New("clipboard monitor already started") + } + ready := make(chan error, 1) + watchCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + runtime.LockOSThread() + defer runtime.UnlockOSThread() + display := C.XOpenDisplay(nil) + if display == nil { + ready <- errors.New("clipboard monitor: no X11 display") + return + } + defer C.XCloseDisplay(display) + var eventBase C.int + if C.watch_clipboard(display, &eventBase) == 0 { + ready <- errors.New("clipboard monitor: XFixes unavailable") + return + } + ready <- nil + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-watchCtx.Done(): + return + case <-ticker.C: + if C.next_clipboard_change(display, eventBase) != 0 { + select { + case monitor.changes <- struct{}{}: + default: + } + } + } + } + }() + if err := <-ready; err != nil { + cancel() + <-done + return err + } + monitor.cancel, monitor.done = cancel, done + return nil +} + +func (monitor *ClipboardMonitor) Stop(ctx context.Context) error { + monitor.mutex.Lock() + cancel, done := monitor.cancel, monitor.done + monitor.mutex.Unlock() + if done == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/platform/xorg/clipboard_native_test.go b/internal/platform/xorg/clipboard_native_test.go new file mode 100644 index 0000000..87bb5ef --- /dev/null +++ b/internal/platform/xorg/clipboard_native_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeClipboardOwnerChange(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + monitor := NewClipboardMonitor() + if err := monitor.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + if err := monitor.Stop(context.Background()); err != nil { + t.Error(err) + } + }() + command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-i") + command.Stdin = strings.NewReader("same text") + if err := command.Run(); err != nil { + t.Fatal(err) + } + select { + case <-monitor.Changes(): + case <-ctx.Done(): + t.Fatal("clipboard owner change was not observed") + } +} diff --git a/internal/platform/xorg/commands.go b/internal/platform/xorg/commands.go new file mode 100644 index 0000000..7ead9f5 --- /dev/null +++ b/internal/platform/xorg/commands.go @@ -0,0 +1,114 @@ +package xorg + +import ( + "context" + "sync" + "sync/atomic" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type CommandListener struct { + secureMutex sync.Mutex + secure atomic.Bool + ocrApplied atomic.Bool + ocrPending atomic.Bool + browse atomic.Bool + browseApplied atomic.Bool + nativeButtonFocus atomic.Bool + nativeButtonApplied atomic.Bool + grabActive atomic.Bool + commandsEnabled atomic.Bool + commandsApplied atomic.Bool + learnMode atomic.Bool + learnEcho atomic.Bool + uiSession atomic.Uint64 + uiSequence atomic.Uint64 + uiClose atomic.Uint64 + uiEcho atomic.Bool + uiUpdates chan uiUpdate + focusChanges chan FocusState + modifiers []string + layout input.KeyboardLayout + bindingOverrides []input.BindingOverride + tentacleBindings []input.BindingOverride + registrationMutex sync.RWMutex + appliedTentacles []input.BindingOverride + bindingUpdates chan bindingUpdate + // Bounded FIFO; overflow fails the listener rather than blocking key releases. + commands chan input.CommandEvent + lockStates chan input.LockState + failures chan error + done chan struct{} + cancel context.CancelFunc + mutex sync.Mutex +} + +type FocusState struct { + Known bool + XTerm bool +} + +func (listener *CommandListener) publishFocusState(state FocusState) { + select { + case listener.focusChanges <- state: + default: + select { + case <-listener.focusChanges: + default: + } + listener.focusChanges <- state + } +} + +func NewCommands(modifiers []string, layout input.KeyboardLayout) *CommandListener { + return &CommandListener{ + modifiers: append([]string(nil), modifiers...), + bindingUpdates: make(chan bindingUpdate), + uiUpdates: make(chan uiUpdate), + layout: layout, + commands: make(chan input.CommandEvent, 16), + lockStates: make(chan input.LockState, 16), + failures: make(chan error, 1), + focusChanges: make(chan FocusState, 1), + } +} + +// SetBrowseNavigation publishes policy; the X11 owner changes grabs between +// complete chords, preserving every key release during a mode transition. +func (listener *CommandListener) SetBrowseNavigation(enabled bool) { listener.browse.Store(enabled) } + +// SetNativeButtonFocus leaves Return to a standard button with real DOM focus. +// Virtual browse targets elsewhere still use Cthulhu's document activation. +func (listener *CommandListener) SetNativeButtonFocus(focused bool) { + listener.nativeButtonFocus.Store(focused) +} + +func (listener *CommandListener) SetCommandGrabs(enabled bool) { + listener.commandsEnabled.Store(enabled) +} + +// BrowseNavigationEnabled reports whether requested browse grabs have reached X11. +func (listener *CommandListener) BrowseNavigationEnabled() bool { + return !listener.ocrApplied.Load() && !listener.secure.Load() && listener.browse.Load() && listener.browseApplied.Load() && listener.commandsApplied.Load() +} + +func (listener *CommandListener) CommandGrabsEnabled() bool { + return (listener.commandsEnabled.Load() && !listener.secure.Load()) && listener.commandsApplied.Load() +} + +// CommandGrabActive reports whether the listener currently owns an explicit +// keyboard grab for a possible command chord. This becomes true on the first +// grabbed press, before the complete chord can be resolved and dispatched. +func (listener *CommandListener) CommandGrabActive() bool { return listener.grabActive.Load() } + +func (listener *CommandListener) LearnModeActive() bool { return listener.learnMode.Load() } +func (listener *CommandListener) LearnModeEchoActive() bool { return listener.learnEcho.Load() } + +func (listener *CommandListener) Commands() <-chan input.CommandEvent { return listener.commands } +func (listener *CommandListener) LockStates() <-chan input.LockState { return listener.lockStates } + +func (listener *CommandListener) Failures() <-chan error { return listener.failures } +func (listener *CommandListener) FocusChanges() <-chan FocusState { + return listener.focusChanges +} diff --git a/internal/platform/xorg/commands_bindings.go b/internal/platform/xorg/commands_bindings.go new file mode 100644 index 0000000..fe62ba2 --- /dev/null +++ b/internal/platform/xorg/commands_bindings.go @@ -0,0 +1,220 @@ +package xorg + +/* +#include "commands_native.h" +#include +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "git.stormux.org/storm/cthulhu/internal/input" + "strings" +) + +type bindingUpdate struct { + tentacles []input.BindingOverride + replaceTentacles bool + ocrForeground uint64 + ocrReview *bool + ocrPending *bool + layout input.KeyboardLayout + modifiers []string + replaceKeyboard bool + ctx context.Context + overrides []input.BindingOverride + result chan error +} + +// SetKeyBindings validates and acknowledges a native update after the current +// chord releases. Before Start it sets the configuration used at startup. +func (listener *CommandListener) SetKeyBindings(ctx context.Context, overrides []input.BindingOverride) error { + return listener.update_keyboard(ctx, bindingUpdate{overrides: overrides}) +} + +// SetKeyboardConfiguration applies layout, modifier keys, and shortcuts together +// after held keys release. A failed native grab retains the whole old setup. +func (listener *CommandListener) SetKeyboardConfiguration(ctx context.Context, layout input.KeyboardLayout, modifiers []string, overrides []input.BindingOverride) error { + if layout != input.DesktopKeyboardLayout && layout != input.LaptopKeyboardLayout { + return fmt.Errorf("invalid keyboard layout %d", layout) + } + if _, err := command_modifier_symbols(modifiers); err != nil { + return err + } + return listener.update_keyboard(ctx, bindingUpdate{layout: layout, modifiers: append([]string(nil), modifiers...), overrides: overrides, replaceKeyboard: true}) +} + +func (listener *CommandListener) update_keyboard(ctx context.Context, update bindingUpdate) error { + if err := ctx.Err(); err != nil { + return err + } + if err := input.ValidateBindingOverrides(update.overrides); err != nil { + return err + } + update.overrides = input.CloneBindingOverrides(update.overrides) + listener.mutex.Lock() + if listener.done == nil { + if update.replaceTentacles { + listener.tentacleBindings = update.tentacles + } else { + listener.bindingOverrides = update.overrides + } + if update.replaceKeyboard { + listener.layout, listener.modifiers = update.layout, update.modifiers + } + listener.mutex.Unlock() + return nil + } + done := listener.done + listener.mutex.Unlock() + update.ctx, update.result = ctx, make(chan error, 1) + select { + case listener.bindingUpdates <- update: + case <-done: + return errors.New("command listener has stopped") + case <-ctx.Done(): + return ctx.Err() + } + select { + case err := <-update.result: + return err + case <-done: + return errors.New("command listener has stopped") + } +} + +// SetTentacleBindings replaces all runtime suggestions atomically after held +// keys release. Native failure restores the previous complete configuration. +func (listener *CommandListener) SetTentacleBindings(ctx context.Context, bindings []input.BindingOverride) error { + if err := input.ValidateTentacleBindings(bindings); err != nil { + return err + } + return listener.update_keyboard(ctx, bindingUpdate{replaceTentacles: true, tentacles: input.CloneBindingOverrides(bindings)}) +} + +func (listener *CommandListener) TentacleBindings() []input.BindingOverride { + listener.registrationMutex.RLock() + defer listener.registrationMutex.RUnlock() + return input.CloneBindingOverrides(listener.appliedTentacles) +} + +func (listener *CommandListener) publishTentacleBindings(merged []input.BindingOverride) { + listener.registrationMutex.Lock() + defer listener.registrationMutex.Unlock() + listener.appliedTentacles = nil + for _, binding := range merged { + if strings.HasPrefix(string(binding.Command), "tentacle/") { + listener.appliedTentacles = append(listener.appliedTentacles, input.CloneBindingOverrides([]input.BindingOverride{binding})[0]) + } + } +} + +func command_modifier_symbols(modifiers []string) ([]uint32, error) { + var symbols []uint32 + for _, name := range modifiers { + switch name { + case "Insert": + symbols = append(symbols, 0xff63) + case "KP_Insert": + symbols = append(symbols, 0xff9e) + case "Caps_Lock": + symbols = append(symbols, 0xffe5) + default: + return nil, fmt.Errorf("command modifier %q is unsupported; use Insert, KP_Insert, or Caps_Lock", name) + } + } + return symbols, nil +} + +func resolvedBindings(layout input.KeyboardLayout, browse bool, overrides []input.BindingOverride) input.CommandBindings { + bindings, _ := input.ResolveCommandBindings(layout, browse, overrides) + return bindings +} + +func configureNativeBindings(device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, overrides []input.BindingOverride) error { + return configure_native_bindings(device, layout, modifiers, overrides, false) +} + +func configure_native_bindings(device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, overrides []input.BindingOverride, ocrReview bool, pending ...bool) error { + symbols, err := command_modifier_symbols(modifiers) + if err != nil { + return err + } + var insertCode, keypadCode, capsCode C.int + for _, symbol := range symbols { + code := C.XKeysymToKeycode(device.display, C.KeySym(symbol)) + if code == 0 { + return fmt.Errorf("Cthulhu modifier %x is unavailable on this X11 keymap", symbol) + } + switch symbol { + case 0xff63: + insertCode = C.int(code) + case 0xff9e: + keypadCode = C.int(code) + case 0xffe5: + capsCode = C.int(code) + } + } + global := resolvedBindings(layout, false, overrides) + browse := resolvedBindings(layout, true, overrides) + if ocrReview { + browse = input.OCRCommandBindings(global) + if len(pending) > 0 && pending[0] { + browse = input.OCRPendingBindings(global) + } + } + customizedBrowse := make(map[input.CommandChord]bool) + for _, override := range overrides { + if override.Layout == layout && override.Scope == "browse" { + for _, key := range override.Keys { + chord, _ := input.ParseCommandChord(key) + customizedBrowse[chord] = true + } + } + } + var native []C.NativeBinding + var extraModifierMask uint16 + for chord, command := range browse { + if chord.CthulhuModifier { + extraModifierMask |= chord.Modifiers & (8 | 64) + // Such commands are delivered after a screen-reader modifier grab. + // Validate that the native keymap can actually produce the symbol. + code := C.XKeysymToKeycode(device.display, C.KeySym(chord.Symbol)) + if code == 0 || uint32(C.XkbKeycodeToKeysym(device.display, code, 0, 0)) != chord.Symbol { + return fmt.Errorf("key %s is unavailable as a base key on this X11 keymap", input.FormatCommandChord(chord)) + } + continue + } + value := C.NativeBinding{symbol: C.ulong(chord.Symbol), modifiers: C.uint(chord.Modifiers)} + if global[chord] == "" || ocrReview { + value.browseOnly = 1 + } + if customizedBrowse[chord] && !ocrReview { + value.probe = 1 + } + if command == input.DocumentActivate { + value.activate = 1 + } + native = append(native, value) + } + // Own Control/Shift from their press in temporary review. Otherwise the + // application can see a modifier press whose release the arrow grab owns. + // Unbound chords are replayed whole after all their physical keys release. + if ocrReview && !(len(pending) > 0 && pending[0]) { + for _, symbol := range []uint32{0xffe1, 0xffe2, 0xffe3, 0xffe4} { + for _, mask := range []uint16{0, 1, 4, 5} { + native = append(native, C.NativeBinding{symbol: C.ulong(symbol), modifiers: C.uint(mask), browseOnly: 1}) + } + } + } + var first *C.NativeBinding + if len(native) != 0 { + first = &native[0] + } + if err := C.configure_command_bindings(device, first, C.int(len(native)), C.uint(extraModifierMask), insertCode, keypadCode, capsCode); err != 0 { + return fmt.Errorf("change X11 key bindings: native error %d (key unavailable or grabbed by another application)", int(err)) + } + return nil +} diff --git a/internal/platform/xorg/commands_chord.go b/internal/platform/xorg/commands_chord.go new file mode 100644 index 0000000..b02a948 --- /dev/null +++ b/internal/platform/xorg/commands_chord.go @@ -0,0 +1,178 @@ +package xorg + +/* +#include "commands_native.h" +#include +*/ +import "C" + +import ( + "errors" + "fmt" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +// Wait for the complete chord: Shift+CapsLock+V remains a version command. +func is_caps_lock_toggle(keys []C.CommandKey) bool { + caps, shift := false, false + for _, key := range keys { + if key.modifiers & ^C.int(1) != 0 { + return false + } + shift = shift || key.modifiers&1 != 0 + switch key.symbol { + case 0xffe5: + caps = true + case 0xffe1, 0xffe2: + shift = true + default: + return false + } + } + return caps && shift +} + +func (owner *commandOwner) handleKey(key C.CommandKey) bool { + owner.listener.grabActive.Store(true) + // Focus policy can change while the native owner waits for an X + // event. Replay a stale browse-only press against the requested + // policy, including custom symbols outside the default key list. + if !owner.learnMode && owner.uiSession == 0 && len(owner.held) == 0 && key.pressed != 0 && !owner.listener.browse.Load() && !owner.ocrReview && + !owner.router.IsModifier(uint32(key.symbol)) && owner.globalBindings[input.CommandChord{Symbol: uint32(key.symbol), Modifiers: uint16(key.modifiers)}] == "" { + owner.passThrough = true + } + if key.pressed != 0 { + owner.held[int(key.code)] = true + } else { + delete(owner.held, int(key.code)) + } + owner.buffered = append(owner.buffered, key) + if owner.uiSession != 0 { + if !owner.handle_internal_key(key) { + return false + } + } else if owner.learnMode { + if !owner.handleLearnKey(key) { + return false + } + } else if !owner.passThrough && !owner.handleRoutedKey(key) { + return false + } + if len(owner.held) == 0 && !owner.finishChord() { + return false + } + return true +} + +func (owner *commandOwner) handleLearnKey(key C.CommandKey) bool { + owner.bound = true + if key.pressed == 0 { + owner.router.Handle(uint32(key.symbol), uint16(key.code), uint16(key.modifiers), false) + return true + } + chord := owner.router.Chord(uint32(key.symbol), uint16(key.modifiers)) + _, command := owner.router.Handle(uint32(key.symbol), uint16(key.code), uint16(key.modifiers), true) + action := input.LearnKey + switch { + case uint32(key.symbol) == 0xff1b || command == input.ToggleLearnMode: + action = input.ToggleLearnMode + case chord.Symbol == 0xffbe && chord.Modifiers == 0 && !chord.CthulhuModifier: + action = input.LearnDocumentation + case chord.Symbol == 0xffbf && chord.Modifiers == 0 && !chord.CthulhuModifier: + action = input.LearnDefaultShortcuts + case chord.Symbol == 0xffc0 && chord.Modifiers == 0 && !chord.CthulhuModifier: + action = input.LearnApplicationShortcuts + } + if action != input.LearnKey { + owner.learnMode = false + owner.listener.learnMode.Store(false) + } + name := "" + if nativeName := C.XKeysymToString(C.KeySym(key.symbol)); nativeName != nil { + name = C.GoString(nativeName) + } + if owner.workerContext.Err() == nil && !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: action, Timestamp: uint32(key.timestamp), Chord: chord, KeyName: name}) { + owner.listener.failures <- errors.New("X11 learn mode queue overflow") + return false + } + return true +} + +func (owner *commandOwner) handleRoutedKey(key C.CommandKey) bool { + consumed, command := owner.router.Handle(uint32(key.symbol), uint16(key.code), uint16(key.modifiers), key.pressed != 0) + if key.pressed != 0 && !owner.router.IsModifier(uint32(key.symbol)) { + if consumed { + owner.bound = true + } else if !owner.bound { + owner.passThrough = true + } + } + if command != "" { + if owner.ocrReview && (command == input.OCRDismiss || command == input.OCRDismissAndPass) { + owner.dismissOCR = true + if command == input.OCRDismissAndPass { + owner.bound = false + owner.passThrough = true + } + } + if command == input.ToggleLearnMode { + if owner.ocrReview { + owner.dismissOCR = true + } + owner.learnMode = true + owner.listener.learnMode.Store(true) + owner.listener.learnEcho.Store(true) + } + uiSession := uint64(0) + if command == input.ShowActions { + if owner.ocrReview && !owner.leave_ocr_review() { + return false + } + var err error + uiSession, err = owner.begin_internal_ui() + if err != nil { + // An unavailable modal grab is a failed command, not a failed reader. + uiSession = 0 + } + } + if owner.workerContext.Err() == nil && !owner.listener.enqueueSecureCommand(input.CommandEvent{UISession: uiSession, Command: command, Timestamp: uint32(key.timestamp), Chord: owner.router.Chord(uint32(key.symbol), uint16(key.modifiers)), LearnModeStarted: command == input.ToggleLearnMode}) { + owner.listener.failures <- errors.New("X11 command queue overflow") + return false + } + } + return true +} + +func (owner *commandOwner) finishChord() bool { + if !owner.bound && owner.device.capsCode != 0 && is_caps_lock_toggle(owner.buffered) { + C.toggle_command_caps_lock(owner.device) + if result := C.replay_command_keys(owner.device, &owner.buffered[0], C.int(len(owner.buffered))); result != 0 { + owner.listener.failures <- fmt.Errorf("release Caps Lock toggle chord: native error %d", int(result)) + return false + } + } else if owner.passThrough || !owner.bound { + result := C.replay_command_keys(owner.device, &owner.buffered[0], C.int(len(owner.buffered))) + if result != 0 { + owner.listener.failures <- fmt.Errorf("replay unbound X11 chord: native error %d", int(result)) + return false + } + } else if !owner.learnMode && owner.uiSession == 0 { + C.release_command_keyboard(owner.device) + } + owner.router.Reset() + owner.buffered = owner.buffered[:0] + owner.bound = false + owner.passThrough = false + if owner.dismissOCR { + owner.dismissOCR = false + if !owner.leave_ocr_review() { + return false + } + } + owner.listener.grabActive.Store(owner.learnMode || owner.uiSession != 0) + owner.listener.uiEcho.Store(owner.uiSession != 0) + owner.listener.learnMode.Store(owner.learnMode) + owner.listener.learnEcho.Store(owner.learnMode) + return true +} diff --git a/internal/platform/xorg/commands_foreground.go b/internal/platform/xorg/commands_foreground.go new file mode 100644 index 0000000..9f0feea --- /dev/null +++ b/internal/platform/xorg/commands_foreground.go @@ -0,0 +1,47 @@ +package xorg + +/* +#include "commands_native.h" +#include +*/ +import "C" + +func ActiveWindowIsXTerm() bool { + return C.cthulhu_active_window_is_xterm() != 0 +} + +// Foreground is an immutable X11 client identity and its screen rectangle. +// Known with Window == 0 means no foreground client, not missing metadata. +type Foreground struct { + Known bool + Window uint64 + PID int + X, Y, Width, Height int + OuterX, OuterY, OuterWidth, OuterHeight int +} + +// ForegroundReader has no goroutine. Its native event-loop owner opens, reads, +// and closes it on that thread; no consumer performs X11 I/O. +type ForegroundReader struct{ display *C.Display } + +func OpenForegroundReader() *ForegroundReader { + display := C.XOpenDisplay(nil) + if display == nil { + return nil + } + return &ForegroundReader{display: display} +} +func (reader *ForegroundReader) Read() Foreground { + if reader == nil { + return Foreground{} + } + value := C.read_foreground(reader.display) + return Foreground{Known: value.known != 0, Window: uint64(value.window), PID: int(value.pid), + X: int(value.x), Y: int(value.y), Width: int(value.width), Height: int(value.height), OuterX: int(value.outerX), OuterY: int(value.outerY), OuterWidth: int(value.outerWidth), OuterHeight: int(value.outerHeight)} +} +func (reader *ForegroundReader) Close() { + if reader != nil { + C.XCloseDisplay(reader.display) + reader.display = nil + } +} diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c new file mode 100644 index 0000000..38810d3 --- /dev/null +++ b/internal/platform/xorg/commands_native.c @@ -0,0 +1,575 @@ + +#include "commands_native.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static pthread_mutex_t error_mutex = PTHREAD_MUTEX_INITIALIZER; +static Display *error_display; +static int grab_error; +static int (*previous_error_handler)(Display *, XErrorEvent *); +static int command_error(Display *display, XErrorEvent *event) { + if (display == error_display) { grab_error = event->error_code; return 0; } + return previous_error_handler ? previous_error_handler(display, event) : 0; +} +// Metadata readers share the keyboard owner's guard because Xlib's error +// handler is process-wide. Each begin/end pair runs in one native call. +void cthulhu_x11_begin_read(Display *display) { + pthread_mutex_lock(&error_mutex); + XSync(display, False); + error_display = display; + grab_error = 0; + previous_error_handler = XSetErrorHandler(command_error); +} +int cthulhu_x11_end_read(Display *display) { + XSync(display, False); + int failed = grab_error; + XSetErrorHandler(previous_error_handler); + error_display = NULL; + pthread_mutex_unlock(&error_mutex); + return failed; +} +static int cthulhu_identifier_is_xterm(const char *identifier) { + if (identifier == NULL) return 0; + char lower[256]; + size_t length = strlen(identifier); + if (length == 0 || length >= sizeof(lower)) return 0; + for (size_t index = 0; index <= length; index++) { + lower[index] = (char)tolower((unsigned char)identifier[index]); + } + const char *base = strrchr(lower, '/'); + base = base != NULL ? base + 1 : lower; + return strcmp(base, "xterm") == 0; +} +static unsigned char *cthulhu_window_property( + Display *display, + Window window, + Atom property, + Atom *type, + int *format, + unsigned long *items +) { + unsigned char *data = NULL; + unsigned long bytesAfter = 0; + if (XGetWindowProperty( + display, + window, + property, + 0, + 1024, + False, + AnyPropertyType, + type, + format, + items, + &bytesAfter, + &data) != Success || data == NULL) { + if (data != NULL) XFree(data); + return NULL; + } + return data; +} +// Follow the mapped child under the pointer. The client PID is usually on a +// descendant of the window-manager frame; keep the first PID found on that path. +PointerPosition cthulhu_read_pointer(Display *display) { + PointerPosition result = {0}; + cthulhu_x11_begin_read(display); + Window root = DefaultRootWindow(display), queryRoot, child; + int rootX, rootY, winX, winY; unsigned int mask; + if (XQueryPointer(display, root, &queryRoot, &child, &result.x, &result.y, + &winX, &winY, &mask)) { + result.known = 1; + Atom pidAtom = XInternAtom(display, "_NET_WM_PID", False); + for (int depth = 0; child != None && depth < 32; depth++) { + Window current = child; + Atom type = None; int format = 0; unsigned long items = 0; + unsigned char *data = cthulhu_window_property(display, current, pidAtom, + &type, &format, &items); + if (data && type == XA_CARDINAL && format == 32 && items == 1) { + result.pid = (int)((unsigned long *)data)[0]; + result.window = current; + } + if (data) XFree(data); + if (!XQueryPointer(display, current, &queryRoot, &child, + &rootX, &rootY, &winX, &winY, &mask)) break; + } + } + if (cthulhu_x11_end_read(display)) result = (PointerPosition){0}; + return result; +} +// Read-only metadata connection, owned by the caller's native event thread. +// All requests which can race window destruction share the Xlib error guard. +Foreground read_foreground(Display *display) { + Foreground result = {0}; + pthread_mutex_lock(&error_mutex); + XSync(display,False); + error_display=display; grab_error=0; + previous_error_handler=XSetErrorHandler(command_error); + Window root=DefaultRootWindow(display), window=None; + Atom type=None; int format=0; unsigned long items=0; + unsigned char *data=cthulhu_window_property(display,root, + XInternAtom(display,"_NET_ACTIVE_WINDOW",False),&type,&format,&items); + if (data && type==XA_WINDOW && format==32 && items==1) { + result.known=1; window=((Window *)data)[0]; + } else { + // Non-EWMH X11 sessions (including the native fixture): resolve + // input focus to its client ancestor. Keyboard grabs do not change it. + int revert; XGetInputFocus(display,&window,&revert); + result.known=1; + for (int depth=0; window!=None && window!=PointerRoot && window!=root && depth<64; depth++) { + Atom stateType=None; int stateFormat=0; unsigned long stateItems=0; + unsigned char *state=cthulhu_window_property(display,window, + XInternAtom(display,"WM_STATE",False),&stateType,&stateFormat,&stateItems); + if (state) XFree(state); + if (stateType!=None) break; + Window treeRoot,parent,*children=NULL; unsigned int count=0; + if (!XQueryTree(display,window,&treeRoot,&parent,&children,&count)) break; + if (children) XFree(children); + if (parent==root || parent==None) break; + window=parent; + } + } + if (data) XFree(data); + if (window!=None && window!=PointerRoot && window!=root) { + XWindowAttributes attributes; + if (XGetWindowAttributes(display,window,&attributes) && attributes.map_state==IsViewable) { + result.window=window; + Window child; + XTranslateCoordinates(display,window,root,0,0,&result.x,&result.y,&child); + result.width=attributes.width; result.height=attributes.height; + result.outerX=result.x; result.outerY=result.y; + result.outerWidth=result.width; result.outerHeight=result.height; + data=cthulhu_window_property(display,window,XInternAtom(display,"_NET_FRAME_EXTENTS",False),&type,&format,&items); + if (data && type==XA_CARDINAL && format==32 && items==4) { + unsigned long *edges=(unsigned long *)data; + if (edges[0]<1024 && edges[1]<1024 && edges[2]<1024 && edges[3]<1024) { + result.outerX-=edges[0]; result.outerY-=edges[2]; + result.outerWidth+=edges[0]+edges[1]; result.outerHeight+=edges[2]+edges[3]; + } + } + if (data) XFree(data); + data=cthulhu_window_property(display,window,XInternAtom(display,"_NET_WM_PID",False),&type,&format,&items); + if (data && type==XA_CARDINAL && format==32 && items==1) result.pid=(int)((unsigned long *)data)[0]; + if (data) XFree(data); + } + } + XSync(display,False); + // A disappearing client is an observed loss, not a process-fatal BadWindow. + if (grab_error) { result.window=0; result.pid=0; } + XSetErrorHandler(previous_error_handler); error_display=NULL; + pthread_mutex_unlock(&error_mutex); + return result; +} +static int cthulhu_display_active_window_xterm_match_unchecked(Display *display) { + Window root = DefaultRootWindow(display); + Atom activeAtom = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + Atom type = None; + int format = 0; + unsigned long items = 0; + unsigned char *data = cthulhu_window_property(display, root, activeAtom, &type, &format, &items); + if (data == NULL || type != XA_WINDOW || format != 32 || items < 1) { + if (data != NULL) XFree(data); + return 0; + } + Window window = ((Window *)data)[0]; + XFree(data); + if (window == None || window == root) { + return 0; + } + + Atom classAtom = XInternAtom(display, "WM_CLASS", False); + data = cthulhu_window_property(display, window, classAtom, &type, &format, &items); + int matched = 0; + int identified = 0; + if (data != NULL && type == XA_STRING && format == 8 && items > 0) { + identified = 1; + char *identifier = (char *)data; + if (cthulhu_identifier_is_xterm(identifier)) matched = 1; + char *classIdentifier = identifier + strlen(identifier) + 1; + if (!matched && (unsigned char *)classIdentifier < data + items) { + matched = cthulhu_identifier_is_xterm(classIdentifier); + } + } + if (data != NULL) XFree(data); + if (matched) { + return 1; + } + if (identified) return 2; + + Atom pidAtom = XInternAtom(display, "_NET_WM_PID", False); + data = cthulhu_window_property(display, window, pidAtom, &type, &format, &items); + long pid = -1; + if (data != NULL && type == XA_CARDINAL && format == 32 && items > 0) { + pid = (long)((unsigned long *)data)[0]; + identified = 1; + } + if (data != NULL) XFree(data); + if (pid > 0) { + char path[64]; + snprintf(path, sizeof(path), "/proc/%ld/cmdline", pid); + FILE *cmdline = fopen(path, "rb"); + if (cmdline != NULL) { + char executable[256]; + size_t length = fread(executable, 1, sizeof(executable) - 1, cmdline); + fclose(cmdline); + executable[length] = '\0'; + matched = cthulhu_identifier_is_xterm(executable); + identified = 1; + } + } + if (matched) return 1; + return identified ? 2 : 0; +} +int cthulhu_display_active_window_xterm_match(Display *display) { + pthread_mutex_lock(&error_mutex); + XSync(display,False); + error_display=display; grab_error=0; + previous_error_handler=XSetErrorHandler(command_error); + int match=cthulhu_display_active_window_xterm_match_unchecked(display); + XSync(display,False); + if (grab_error) match=0; + XSetErrorHandler(previous_error_handler); error_display=NULL; + pthread_mutex_unlock(&error_mutex); + return match; +} +int cthulhu_active_window_is_xterm(void) { + Display *display = XOpenDisplay(NULL); + if (display == NULL) return 0; + int match = cthulhu_display_active_window_xterm_match(display); + XCloseDisplay(display); + return match == 1; +} +// Ignore Scroll Lock only when its modifier slot has no other meaning. In +// particular, a slot shared with AltGr/level switching must remain significant. +static unsigned int scroll_lock_modifiers(Display *display) { + unsigned int mask = XkbKeysymToModifiers(display, XK_Scroll_Lock) & + ~(ShiftMask | ControlMask | Mod1Mask | Mod4Mask); + if (!mask) return 0; + XModifierKeymap *mapping = XGetModifierMapping(display); + if (mapping == NULL) return 0; + int minCode, maxCode, symbolsPerCode; + XDisplayKeycodes(display, &minCode, &maxCode); + KeySym *symbols = XGetKeyboardMapping(display, minCode, + maxCode - minCode + 1, &symbolsPerCode); + if (symbols == NULL) { XFreeModifiermap(mapping); return 0; } + for (int slot = 0; slot < 8; slot++) { + unsigned int bit = 1u << slot; + if (!(mask & bit)) continue; + for (int index = 0; index < mapping->max_keypermod; index++) { + int code = mapping->modifiermap[slot * mapping->max_keypermod + index]; + if (!code) continue; + if (code < minCode || code > maxCode) { mask &= ~bit; break; } + for (int symbolIndex = 0; symbolIndex < symbolsPerCode; symbolIndex++) { + KeySym symbol = symbols[(code - minCode) * symbolsPerCode + symbolIndex]; + if (symbol != NoSymbol && symbol != XK_Scroll_Lock) mask &= ~bit; + } + } + } + XFree(symbols); + XFreeModifiermap(mapping); + return mask; +} +static void grab_command_keys(CommandDevice *device) { + // Modifier-first chords must be observed even before the command key is + // known. Lock modifiers are transparent; NumLock preserves keypad typing. + unsigned int allowedModifiers = device->ignoredModifierMask | + ControlMask | ShiftMask | device->extraModifierMask; + for (unsigned int mask=0;mask<256;mask++) { + if (mask & ~allowedModifiers) continue; + if (device->capsCode) XGrabKey(device->display,device->capsCode,mask,device->root,False,GrabModeAsync,GrabModeSync); + if (device->insertCode) XGrabKey(device->display,device->insertCode,mask,device->root,False,GrabModeAsync,GrabModeSync); + if (device->keypadCode && !(mask & device->numlockMask)) XGrabKey(device->display,device->keypadCode,mask,device->root,False,GrabModeAsync,GrabModeSync); + } + for (int j=0;jbindingCount;j++) { + NativeBinding binding=device->bindings[j]; + if (binding.browseOnly && !device->browse && !(device->probeBindings && binding.probe)) continue; + int code=XKeysymToKeycode(device->display,binding.symbol); + if (!code) continue; + for (unsigned int mask=0;mask<256;mask++) { + if ((mask & ~device->ignoredModifierMask) != binding.modifiers) continue; + // Non-numeric keypad navigation has a different meaning with NumLock. + if (binding.symbol>=XK_KP_Home && binding.symbol<=XK_KP_Delete && (mask & device->numlockMask)) continue; + XGrabKey(device->display,code,mask,device->root,False,GrabModeAsync,GrabModeSync); + } + } +} +int set_command_keys(CommandDevice *device, int enabled) { + pthread_mutex_lock(&error_mutex); + XSync(device->display,False); + error_display=device->display; grab_error=0; + previous_error_handler=XSetErrorHandler(command_error); + XUngrabKey(device->display,AnyKey,AnyModifier,device->root); + if (!enabled) { + // A synchronous passive grab may already have activated before Go + // consumes its press. XUngrabKey only removes future registrations. + XUngrabKeyboard(device->display,CurrentTime); + device->active=0; + } + if (enabled) grab_command_keys(device); + XSync(device->display,False); + int error=grab_error; + if (error && enabled) { + XUngrabKey(device->display,AnyKey,AnyModifier,device->root); + XSync(device->display,False); + } + XSetErrorHandler(previous_error_handler); error_display=NULL; + pthread_mutex_unlock(&error_mutex); + device->commands=enabled && !error; + return error; +} +// Replace passive grabs transactionally. A conflicting grab restores the old +// configuration so preferences can report the failure without losing controls. +int configure_command_bindings(CommandDevice *device, NativeBinding *bindings, int count, unsigned int extraModifierMask, int insertCode, int keypadCode, int capsCode) { + NativeBinding *copy=count ? malloc(sizeof(NativeBinding)*count) : NULL; + if (count && !copy) return -3; + if (count) memcpy(copy,bindings,sizeof(NativeBinding)*count); + for (int i=0;idisplay,copy[i].symbol); + if (!code || XkbKeycodeToKeysym(device->display,code,0,0)!=copy[i].symbol) { free(copy); return -2; } + } + int previousInsert=device->insertCode, previousKeypad=device->keypadCode, previousCaps=device->capsCode; + NativeBinding *previous=device->bindings; + int previousCount=device->bindingCount, enabled=device->commands; + unsigned int previousMask=device->extraModifierMask; + device->bindings=copy; device->bindingCount=count; device->extraModifierMask=extraModifierMask; + device->insertCode=insertCode; device->keypadCode=keypadCode; device->capsCode=capsCode; + // Probe newly customized browse-only chords even outside browse mode, so + // Apply can reject a conflict instead of failing on the next mode change. + for (int i=0;ibrowse) device->probeBindings=1; + int probing=device->probeBindings; + int error=set_command_keys(device,enabled); + device->probeBindings=0; + if (!error && probing) error=set_command_keys(device,enabled); + if (error) { + device->bindings=previous; device->bindingCount=previousCount; device->extraModifierMask=previousMask; + device->insertCode=previousInsert; device->keypadCode=previousKeypad; device->capsCode=previousCaps; + int restoreError=set_command_keys(device,enabled); + free(copy); + return restoreError ? -6 : error; + } + free(previous); + if (capsCode && !previousCaps) { + device->capsLocked=0; + XkbLockModifiers(device->display,XkbUseCoreKbd,LockMask,0); + XSync(device->display,False); + } + return 0; +} +int set_browse_keys(CommandDevice *device, int enabled) { + int previous=device->browse; + device->browse=enabled; + int error=set_command_keys(device,device->commands); + if (error) { + device->browse=previous; + set_command_keys(device,1); + } + device->browseWindow=read_foreground(device->display).window; + return error; +} +CommandDevice *open_commands(int useInsert, int useKeypad, int useCaps, int *error) { + CommandDevice *device = calloc(1,sizeof(CommandDevice)); + if (!device) { *error=-3; return NULL; } + device->display = XOpenDisplay(NULL); + if (!device->display) { free(device); *error = -1; return NULL; } + int eventBase, errorBase, majorVersion, minorVersion; + if (!XTestQueryExtension(device->display,&eventBase,&errorBase,&majorVersion,&minorVersion)) { + XCloseDisplay(device->display); free(device); *error=-5; return NULL; + } + Bool repeatSupported=False; + if (!XkbSetDetectableAutoRepeat(device->display,True,&repeatSupported) || !repeatSupported) { + XCloseDisplay(device->display); free(device); *error=-4; return NULL; + } + device->root = DefaultRootWindow(device->display); + int xkbOpcode, xkbError, xkbMajor=XkbMajorVersion, xkbMinor=XkbMinorVersion; + if (!XkbQueryExtension(device->display,&xkbOpcode,&device->xkbEvent,&xkbError,&xkbMajor,&xkbMinor)) { + XCloseDisplay(device->display); free(device); *error=-7; return NULL; + } + const char *lockNames[]={"Caps Lock","Num Lock","Scroll Lock"}; + for (int i=0;i<3;i++) { + int index; Bool state; + if (XkbGetNamedIndicator(device->display,XInternAtom(device->display,lockNames[i],False),&index,&state,NULL,NULL)) { + device->lockIndicators[i]=1u<display,XkbUseCoreKbd,XkbIndicatorStateNotifyMask,XkbIndicatorStateNotifyMask); + device->insertCode = useInsert ? XKeysymToKeycode(device->display,XK_Insert) : 0; + device->keypadCode = useKeypad ? XKeysymToKeycode(device->display,XK_KP_Insert) : 0; + device->capsCode = useCaps ? XKeysymToKeycode(device->display,XK_Caps_Lock) : 0; + device->numlockMask = XkbKeysymToModifiers(device->display,XK_Num_Lock); + device->ignoredModifierMask = LockMask | device->numlockMask | + scroll_lock_modifiers(device->display); + if ((useInsert && !device->insertCode) || (useKeypad && !device->keypadCode) || (useCaps && !device->capsCode)) { + XCloseDisplay(device->display); free(device); *error=-2; return NULL; + } + *error=set_command_keys(device,1); + if (*error) { XCloseDisplay(device->display); free(device); return NULL; } + if (device->capsCode) { + XkbLockModifiers(device->display,XkbUseCoreKbd,LockMask,0); + XSync(device->display,False); + } + return device; +} +int command_lock_state(CommandDevice *device) { + unsigned int indicators=0; + XkbGetIndicatorState(device->display,XkbUseCoreKbd,&indicators); + int locks=0; + for (int i=0;i<3;i++) if (indicators & device->lockIndicators[i]) locks |= 1<capsCode) locks=(locks & ~1) | device->capsLocked; + return locks; +} +void toggle_command_caps_lock(CommandDevice *device) { + device->capsLocked=!device->capsLocked; + XkbLockModifiers(device->display,XkbUseCoreKbd,LockMask,device->capsLocked ? LockMask:0); + XSync(device->display,False); +} +int next_command_key(CommandDevice *device, CommandKey *key) { + if (!XPending(device->display)) { + struct pollfd fd={ConnectionNumber(device->display),POLLIN,0}; + int result=poll(&fd,1,20); + if (result<0 && errno==EINTR) return 0; + if (result<0 || (fd.revents & (POLLERR|POLLHUP|POLLNVAL))) return -1; + if (result==0) return 0; + } + XEvent event; + XNextEvent(device->display,&event); + if (event.type==device->xkbEvent && ((XkbEvent *)&event)->any.xkb_type==XkbIndicatorStateNotify) { + key->locks=command_lock_state(device); + key->timestamp=((XkbEvent *)&event)->indicators.time; + return 2; + } + if (event.type!=KeyPress && event.type!=KeyRelease) return 0; + // Events captured before suspension can still be queued in Xlib after + // the grabs are released. Discard that old reader input; it must neither + // dispatch commands nor acquire a new explicit grab in the delegated app. + if (!device->commands) return 0; + // Preserve the intentional capitalization state on both modifier edges. + if (device->capsCode && event.xkey.keycode==(unsigned)device->capsCode) { + XkbLockModifiers(device->display,XkbUseCoreKbd,LockMask,device->capsLocked ? LockMask:0); + XSync(device->display,False); + } + if (!device->active && event.type==KeyPress) { + KeySym symbol=XLookupKeysym(&event.xkey,0); + unsigned int modifiers=event.xkey.state & ~device->ignoredModifierMask; + int browseOnly=0, activate=0; + for (int i=0;ibindingCount;i++) { + NativeBinding binding=device->bindings[i]; + if (binding.symbol==symbol && binding.modifiers==modifiers) { + browseOnly=binding.browseOnly; activate=binding.activate; break; + } + } + if (browseOnly) { + Foreground foreground=read_foreground(device->display); + if (!device->browse || !foreground.known || foreground.window==0 || + foreground.window!=device->browseWindow) { + XAllowEvents(device->display,ReplayKeyboard,event.xkey.time); XFlush(device->display); return 0; + } + } + // Python leaves Return on a natively focused standard button to the + // application. Replay the real press before taking an explicit grab. + if (symbol==XK_Return && activate && device->browse && device->nativeButtonFocus && + !(event.xkey.state & (ControlMask|ShiftMask))) { + XAllowEvents(device->display,ReplayKeyboard,event.xkey.time); XFlush(device->display); return 0; + } + // Upgrade our passive Insert grab to an explicit grab. Unlike a passive + // grab, this stays active if Insert is released before the command key. + int status=XGrabKeyboard(device->display,device->root,False,GrabModeAsync,GrabModeAsync,event.xkey.time); + if (status!=GrabSuccess) { XAllowEvents(device->display,ReplayKeyboard,event.xkey.time); XFlush(device->display); return -2; } + device->active=1; + } + key->pressed=(event.type==KeyPress); key->code=event.xkey.keycode; + key->symbol=XLookupKeysym(&event.xkey,0); + key->modifiers=event.xkey.state & ~device->ignoredModifierMask; + key->text[0]=0; key->textSymbol=0; + if (device->internalUI && event.type==KeyPress) { + char ignored[64]; KeySym translated; + XLookupString(&event.xkey,ignored,sizeof(ignored),&translated,NULL); + key->textSymbol=translated; + if (!(event.xkey.state & (ControlMask|Mod1Mask|Mod4Mask))) { + if (device->composeState) xkb_compose_state_feed(device->composeState,translated); + enum xkb_compose_status status=device->composeState ? xkb_compose_state_get_status(device->composeState) : XKB_COMPOSE_NOTHING; + if (status==XKB_COMPOSE_COMPOSED) { + xkb_compose_state_get_utf8(device->composeState,key->text,sizeof(key->text)); + xkb_compose_state_reset(device->composeState); + } else if (status==XKB_COMPOSE_CANCELLED) { + xkb_compose_state_reset(device->composeState); + } else if (status==XKB_COMPOSE_NOTHING) { + xkb_keysym_to_utf8(translated,key->text,sizeof(key->text)); + } + } else { + xkb_keysym_to_utf8(XLookupKeysym(&event.xkey,0),key->text,sizeof(key->text)); + } + } + key->timestamp=event.xkey.time; + return 1; +} +void release_command_keyboard(CommandDevice *device) { + XUngrabKeyboard(device->display,CurrentTime); XFlush(device->display); device->active=0; +} +int replay_command_keys(CommandDevice *device, CommandKey *keys, int count) { + pthread_mutex_lock(&error_mutex); + XSync(device->display,False); + error_display=device->display; grab_error=0; + previous_error_handler=XSetErrorHandler(command_error); + XUngrabKeyboard(device->display,CurrentTime); + XUngrabKey(device->display,AnyKey,AnyModifier,device->root); + device->active=0; + int injected=1; + for (int i=0;icapsCode && keys[i].code==device->capsCode) continue; + if (!XTestFakeKeyEvent(device->display,keys[i].code,keys[i].pressed,CurrentTime)) injected=0; + } + if (device->commands) grab_command_keys(device); + XSync(device->display,False); + XSetErrorHandler(previous_error_handler); + int error=grab_error; + error_display=NULL; + pthread_mutex_unlock(&error_mutex); + if (!injected) return -1; + return error; +} +void close_commands(CommandDevice *device) { + if (device->composeState) xkb_compose_state_unref(device->composeState); + if (device->composeTable) xkb_compose_table_unref(device->composeTable); + if (device->textContext) xkb_context_unref(device->textContext); + if (device->active) release_command_keyboard(device); + XUngrabKey(device->display,AnyKey,AnyModifier,device->root); + XSync(device->display,False); XCloseDisplay(device->display); free(device->bindings); free(device); +} + +int acquire_internal_keyboard(CommandDevice *device) { + if (!device->active) { + int status=XGrabKeyboard(device->display,device->root,False,GrabModeAsync,GrabModeAsync,CurrentTime); + if (status!=GrabSuccess) return status; + device->active=1; + } + if (!device->textContext) { + device->textContext=xkb_context_new(XKB_CONTEXT_NO_FLAGS); + const char *locale=getenv("LC_ALL"); + if (!locale || !*locale) locale=getenv("LC_CTYPE"); + if (!locale || !*locale) locale=getenv("LANG"); + if (!locale || !*locale) locale="C"; + if (device->textContext) device->composeTable=xkb_compose_table_new_from_locale(device->textContext,locale,XKB_COMPOSE_COMPILE_NO_FLAGS); + if (device->composeTable) device->composeState=xkb_compose_state_new(device->composeTable,XKB_COMPOSE_STATE_NO_FLAGS); + } + if (device->composeState) xkb_compose_state_reset(device->composeState); + device->internalUI=1; + return 0; +} +void reset_internal_compose(CommandDevice *device) { + device->internalUI=0; + if (device->composeState) xkb_compose_state_reset(device->composeState); +} diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h new file mode 100644 index 0000000..5eacfd3 --- /dev/null +++ b/internal/platform/xorg/commands_native.h @@ -0,0 +1,54 @@ +#ifndef CTHULHU_COMMANDS_NATIVE_H +#define CTHULHU_COMMANDS_NATIVE_H + +#include + +typedef struct { unsigned long symbol; unsigned int modifiers; int browseOnly, activate, probe; } NativeBinding; +typedef struct { + NativeBinding *bindings; + int bindingCount, probeBindings; + unsigned int extraModifierMask; + Display *display; + Window root; + int insertCode, keypadCode, capsCode, numlockMask; + unsigned int ignoredModifierMask; + int xkbEvent, capsLocked; + struct xkb_context *textContext; + struct xkb_compose_table *composeTable; + struct xkb_compose_state *composeState; + unsigned int lockIndicators[3]; + int commands; + int active; + int internalUI; + int browse; + int nativeButtonFocus; + Window browseWindow; +} CommandDevice; +typedef struct { int pressed, code, symbol, textSymbol, modifiers, locks; unsigned long timestamp; char text[64]; } CommandKey; +typedef struct { + int known, pid, x, y, width, height, outerX, outerY, outerWidth, outerHeight; + unsigned long window; +} Foreground; +typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition; + +int cthulhu_active_window_is_xterm(void); +int cthulhu_display_active_window_xterm_match(Display *display); +Foreground read_foreground(Display *display); +PointerPosition cthulhu_read_pointer(Display *display); +void cthulhu_x11_begin_read(Display *display); +int cthulhu_x11_end_read(Display *display); +int set_command_keys(CommandDevice *device, int enabled); +int configure_command_bindings(CommandDevice *device, NativeBinding *bindings, int count, unsigned int extraModifierMask, int insertCode, int keypadCode, int capsCode); +int set_browse_keys(CommandDevice *device, int enabled); +CommandDevice *open_commands(int useInsert, int useKeypad, int useCaps, int *error); +int command_lock_state(CommandDevice *device); +void toggle_command_caps_lock(CommandDevice *device); +int next_command_key(CommandDevice *device, CommandKey *key); +int acquire_internal_keyboard(CommandDevice *device); +void reset_internal_compose(CommandDevice *device); +void release_command_keyboard(CommandDevice *device); +int replay_command_keys(CommandDevice *device, CommandKey *keys, int count); +void close_commands(CommandDevice *device); +int cthulhu_ocr_click(unsigned long foreground, int x, int y, int root_width, int root_height); + +#endif diff --git a/internal/platform/xorg/commands_ocr.go b/internal/platform/xorg/commands_ocr.go new file mode 100644 index 0000000..447ad9b --- /dev/null +++ b/internal/platform/xorg/commands_ocr.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#include "commands_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +// SetOCRReview acknowledges a transactional context change only after every +// held chord has released. It belongs on a session worker, not the command +// consumer. Failure retains the old context. Secure/terminal policy still wins. +// The session must dismiss on real focus changes and ordinary XRecord input. +func (listener *CommandListener) SetOCRReview(ctx context.Context, enabled bool) error { + return listener.set_ocr_context(ctx, enabled, false, 0) +} + +// SetOCRPending owns Escape only; recognition never claims document navigation. +func (listener *CommandListener) SetOCRPending(ctx context.Context, enabled bool) error { + return listener.set_ocr_context(ctx, enabled, enabled, 0) +} + +// SetOCRContext admits a live session only in its expected foreground client. +func (listener *CommandListener) SetOCRContext(ctx context.Context, foreground uint64, pending bool) error { + if foreground == 0 { + return errors.New("OCR foreground unavailable") + } + return listener.set_ocr_context(ctx, true, pending, foreground) +} + +func (listener *CommandListener) set_ocr_context(ctx context.Context, enabled, pending bool, foreground uint64) error { + if err := ctx.Err(); err != nil { + return err + } + listener.mutex.Lock() + done := listener.done + listener.mutex.Unlock() + if done == nil { + return errors.New("command listener is not running") + } + update := bindingUpdate{ctx: ctx, ocrReview: &enabled, ocrPending: &pending, ocrForeground: foreground, result: make(chan error, 1)} + select { + case listener.bindingUpdates <- update: + case <-ctx.Done(): + return ctx.Err() + case <-done: + return errors.New("command listener has stopped") + } + select { + case err := <-update.result: + return err + case <-ctx.Done(): + return ctx.Err() + case <-done: + return errors.New("command listener has stopped") + } +} + +func (listener *CommandListener) OCRReviewEnabled() bool { + return !listener.ocrPending.Load() && !listener.secure.Load() && listener.commandsEnabled.Load() && listener.commandsApplied.Load() && listener.ocrApplied.Load() +} + +func (owner *commandOwner) context_bindings() input.CommandBindings { + if owner.ocrReview { + if owner.ocrPending { + return input.OCRPendingBindings(owner.globalBindings) + } + return input.OCRCommandBindings(owner.globalBindings) + } + return resolvedBindings(owner.layout, owner.device.browse != 0, owner.overrides) +} + +// Configure grabs and routing together. Native replacement restores old +// bindings on error; restore its old mode too before reporting a failed entry. +func configure_context_bindings(device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, overrides []input.BindingOverride, browse, ocrReview bool, pending ...bool) error { + previous, enabled := device.browse, device.commands + device.browse = 0 + if browse || ocrReview { + device.browse = 1 + } + err := configure_native_bindings(device, layout, modifiers, overrides, ocrReview, pending...) + if err != nil { + device.browse = previous + if result := C.set_command_keys(device, enabled); result != 0 { + return fmt.Errorf("restore X11 input context: native error %d", int(result)) + } + return err + } + device.browseWindow = C.read_foreground(device.display).window + return nil +} + +func (owner *commandOwner) leave_ocr_review() bool { + if !owner.ocrReview { + return true + } + if err := configure_context_bindings(owner.device, owner.layout, owner.modifiers, owner.overrides, owner.listener.browse.Load(), false); err != nil { + owner.listener.failures <- err + return false + } + owner.ocrReview, owner.ocrPending = false, false + owner.listener.ocrPending.Store(false) + owner.listener.ocrApplied.Store(false) + owner.listener.browseApplied.Store(owner.device.browse != 0) + owner.router.ReplaceBindings(owner.context_bindings()) + return true +} diff --git a/internal/platform/xorg/commands_owner.go b/internal/platform/xorg/commands_owner.go new file mode 100644 index 0000000..12bbe2a --- /dev/null +++ b/internal/platform/xorg/commands_owner.go @@ -0,0 +1,113 @@ +package xorg + +/* +#cgo pkg-config: x11 xtst +#include "commands_native.h" +#include +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "runtime" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +const xtermFocusCheckInterval = 100 * time.Millisecond + +func (listener *CommandListener) Start(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + listener.mutex.Lock() + defer listener.mutex.Unlock() + if listener.done != nil { + return errors.New("command listener already started") + } + symbols, err := command_modifier_symbols(listener.modifiers) + if err != nil { + return err + } + var useInsert, useKeypad, useCaps C.int + for _, symbol := range symbols { + switch symbol { + case 0xff63: + useInsert = 1 + case 0xff9e: + useKeypad = 1 + case 0xffe5: + useCaps = 1 + } + } + workerContext, cancel := context.WithCancel(ctx) + listener.cancel = cancel + listener.done = make(chan struct{}) + setup := make(chan error, 1) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer close(listener.done) + defer listener.grabActive.Store(false) + defer listener.learnMode.Store(false) + defer listener.learnEcho.Store(false) + defer listener.uiSession.Store(0) + defer listener.uiEcho.Store(false) + var nativeError C.int + listener.commandsEnabled.Store(true) + device := C.open_commands(useInsert, useKeypad, useCaps, &nativeError) + if device == nil { + setup <- fmt.Errorf("open X11 command listener: native error %d (a conflicting command-key grab may already exist)", int(nativeError)) + return + } + defer C.close_commands(device) + defer listener.browseApplied.Store(false) + defer listener.ocrApplied.Store(false) + defer listener.commandsApplied.Store(false) + listener.commandsApplied.Store(true) + layout := listener.layout + modifiers := append([]string(nil), listener.modifiers...) + core := input.CloneBindingOverrides(listener.bindingOverrides) + tentacles := input.CloneBindingOverrides(listener.tentacleBindings) + overrides := input.MergeTentacleBindings(core, tentacles) + if err := configureNativeBindings(device, layout, modifiers, overrides); err != nil { + setup <- err + return + } + listener.publishTentacleBindings(overrides) + defer listener.publishTentacleBindings(nil) + setup <- nil + listener.runCommandLoop(workerContext, device, layout, modifiers, core, tentacles, symbols) + }() + select { + case err := <-setup: + if err != nil { + cancel() + <-listener.done + } + return err + case <-ctx.Done(): + cancel() + <-listener.done + return ctx.Err() + } +} + +func (listener *CommandListener) Stop(ctx context.Context) error { + listener.mutex.Lock() + cancel, done := listener.cancel, listener.done + listener.mutex.Unlock() + if done == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go new file mode 100644 index 0000000..6f72d16 --- /dev/null +++ b/internal/platform/xorg/commands_owner_loop.go @@ -0,0 +1,280 @@ +package xorg + +/* +#include "commands_native.h" +#include +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +// commandOwner is confined to the X11 command goroutine and its locked OS thread. +// Its chord buffer and router are reset on completed chords and secure input; +// the whole owner is discarded when the listener stops. +type commandOwner struct { + listener *CommandListener + workerContext context.Context + device *C.CommandDevice + layout input.KeyboardLayout + modifiers []string + overrides []input.BindingOverride + core, tentacles []input.BindingOverride + symbols []uint32 + globalBindings input.CommandBindings + router *input.CommandRouter + held map[int]bool + buffered []C.CommandKey + bound, passThrough, learnMode bool + ocrReview, dismissOCR bool + ocrPending bool + uiSession uint64 + uiWindow, uiFocus C.Window + lastLocks, lastFocusMatch C.int + lastFocusCheck, drainDeadline time.Time +} + +func (listener *CommandListener) runCommandLoop(workerContext context.Context, device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, core, tentacles []input.BindingOverride, symbols []uint32) { + owner := &commandOwner{listener: listener, workerContext: workerContext, device: device, layout: layout, modifiers: modifiers, core: core, tentacles: tentacles, overrides: input.MergeTentacleBindings(core, tentacles), symbols: symbols, held: make(map[int]bool)} + owner.run() +} + +func (owner *commandOwner) run() { + owner.publishFocus() + owner.globalBindings = resolvedBindings(owner.layout, false, owner.overrides) + owner.router = input.NewCommandRouter(owner.symbols, owner.globalBindings) + owner.lastLocks = C.command_lock_state(owner.device) + for { + if owner.workerContext.Err() != nil { + if !owner.bound || len(owner.held) == 0 { + break + } + // Quit dispatches on press. Keep consuming its physical releases + // during shutdown, bounded in case a device disappears. + if owner.drainDeadline.IsZero() { + owner.drainDeadline = time.Now().Add(time.Second) + } + if time.Now().After(owner.drainDeadline) { + break + } + } + if !owner.sync_internal_ui() { + return + } + if !owner.applyBindingUpdate() { + return + } + if time.Since(owner.lastFocusCheck) >= xtermFocusCheckInterval { + owner.publishFocus() + } + owner.resetForSecureInput() + if !owner.syncGrabs() { + return + } + var key C.CommandKey + result := C.next_command_key(owner.device, &key) + if result < 0 { + owner.listener.failures <- fmt.Errorf("X11 command listener failed: %d", int(result)) + return + } + if result == 0 { + continue + } + if result == 2 { + owner.publishLocks(key) + continue + } + if !owner.handleKey(key) { + return + } + } + if len(owner.buffered) != 0 && !owner.bound { + result := C.replay_command_keys(owner.device, &owner.buffered[0], C.int(len(owner.buffered))) + if result != 0 { + owner.listener.failures <- fmt.Errorf("replay interrupted X11 chord: native error %d", int(result)) + } + } +} + +func (owner *commandOwner) publishFocus() { + match := C.cthulhu_display_active_window_xterm_match(owner.device.display) + owner.lastFocusCheck = time.Now() + if match == 0 || match == owner.lastFocusMatch { + return + } + owner.lastFocusMatch = match + state := FocusState{Known: true, XTerm: match == 1} + owner.listener.SetCommandGrabs(!state.XTerm) + owner.listener.publishFocusState(state) +} + +// Native xterm focus vetoes delayed accessibility requests to restore grabs. +// lastFocusMatch belongs to this owner and survives unknown focus samples; +// only a known non-xterm window ends the handoff. +func (owner *commandOwner) commandGrabsAllowed() bool { + return owner.lastFocusMatch != 1 && owner.listener.commandsEnabled.Load() && !owner.listener.secure.Load() +} + +func (owner *commandOwner) applyBindingUpdate() bool { + if len(owner.held) == 0 { + select { + case update := <-owner.listener.bindingUpdates: + nextLayout, nextModifiers := owner.layout, owner.modifiers + if update.replaceKeyboard { + nextLayout, nextModifiers = update.layout, update.modifiers + } + nextCore, nextTentacles := owner.core, owner.tentacles + if update.replaceTentacles { + nextTentacles = update.tentacles + } else if update.ocrReview == nil { + nextCore = update.overrides + } + nextOverrides, nextOCR := input.MergeTentacleBindings(nextCore, nextTentacles), owner.ocrReview + nextPending := owner.ocrPending + if update.ocrPending != nil { + nextPending = *update.ocrPending + } + if update.ocrReview != nil { + nextOverrides, nextOCR = owner.overrides, *update.ocrReview + } + err := update.ctx.Err() + if err == nil && nextOCR && (!owner.commandGrabsAllowed() || owner.learnMode) { + err = errors.New("OCR review input unavailable") + } + if err == nil && nextOCR { + foreground := C.read_foreground(owner.device.display) + if foreground.known == 0 || foreground.window == 0 || update.ocrForeground != 0 && uint64(foreground.window) != update.ocrForeground { + err = errors.New("OCR foreground unavailable") + } + } + if err == nil { + err = configure_context_bindings(owner.device, nextLayout, nextModifiers, nextOverrides, owner.listener.browse.Load(), nextOCR, nextPending) + } + if err == nil { + owner.layout, owner.modifiers, owner.overrides = nextLayout, nextModifiers, nextOverrides + owner.core, owner.tentacles = nextCore, nextTentacles + owner.listener.publishTentacleBindings(nextOverrides) + owner.ocrReview, owner.ocrPending = nextOCR, nextPending + owner.listener.ocrPending.Store(nextPending) + owner.listener.ocrApplied.Store(nextOCR) + owner.listener.browseApplied.Store(owner.device.browse != 0 && !nextOCR) + owner.symbols, _ = command_modifier_symbols(owner.modifiers) + owner.globalBindings = resolvedBindings(owner.layout, false, owner.overrides) + owner.router = input.NewCommandRouter(owner.symbols, owner.context_bindings()) + } + + update.result <- err + if owner.device.commands == 0 && owner.listener.commandsApplied.Load() { + owner.listener.failures <- errors.New("X11 could not restore command grabs") + return false + } + default: + } + } + return true +} + +func (owner *commandOwner) resetForSecureInput() { + if owner.listener.secure.Load() && (owner.ocrReview || owner.device.commands != 0 || owner.device.active != 0 || len(owner.held) != 0) { + C.release_command_keyboard(owner.device) + C.set_command_keys(owner.device, 0) + owner.listener.commandsApplied.Store(false) + owner.listener.grabActive.Store(false) + owner.learnMode = false + owner.listener.learnMode.Store(false) + owner.listener.learnEcho.Store(false) + owner.dismiss_internal_ui() + owner.listener.uiEcho.Store(false) + clear(owner.held) + owner.buffered = nil + owner.bound, owner.passThrough = false, false + owner.ocrReview, owner.ocrPending = false, false + owner.listener.ocrPending.Store(false) + owner.listener.ocrApplied.Store(false) + if err := configure_context_bindings(owner.device, owner.layout, owner.modifiers, owner.overrides, false, false); err != nil { + owner.listener.failures <- err + } + owner.router = input.NewCommandRouter(owner.symbols, resolvedBindings(owner.layout, false, owner.overrides)) + } +} + +func (owner *commandOwner) syncGrabs() bool { + if owner.ocrReview && len(owner.held) == 0 { + foreground := C.read_foreground(owner.device.display) + if foreground.known == 0 || foreground.window == 0 || foreground.window != owner.device.browseWindow { + if !owner.leave_ocr_review() { + return false + } + if !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: input.OCRDismiss}) { + return false + } + } + } + enabled := owner.commandGrabsAllowed() + if owner.ocrReview && !enabled && len(owner.held) == 0 { + if !owner.leave_ocr_review() { + return false + } + } + if len(owner.held) == 0 && !enabled && owner.learnMode { + // Learn mode keeps an explicit keyboard grab between chords. Removing + // passive command grabs alone cannot give that keyboard back to Fenrir. + C.release_command_keyboard(owner.device) + owner.learnMode = false + owner.router.Reset() + owner.listener.grabActive.Store(false) + owner.listener.learnMode.Store(false) + owner.listener.learnEcho.Store(false) + } + if len(owner.held) == 0 && (owner.device.commands != 0) != enabled { + value := C.int(0) + if enabled { + value = 1 + } + if err := C.set_command_keys(owner.device, value); err != 0 { + owner.listener.failures <- fmt.Errorf("change X11 command grabs: native error %d", int(err)) + return false + } + owner.listener.commandsApplied.Store(enabled) + } + if !owner.ocrReview && len(owner.held) == 0 && (owner.device.commands != 0) && + (owner.device.browse != 0) != owner.listener.browse.Load() { + enabled := owner.listener.browse.Load() + value := C.int(0) + if enabled { + value = 1 + } + if err := C.set_browse_keys(owner.device, value); err != 0 { + owner.listener.failures <- fmt.Errorf("change X11 browse grabs: native error %d", int(err)) + return false + } + bindings := resolvedBindings(owner.layout, false, owner.overrides) + if enabled { + bindings = resolvedBindings(owner.layout, true, owner.overrides) + } + owner.router.ReplaceBindings(bindings) + owner.listener.browseApplied.Store(enabled) + } + owner.device.nativeButtonFocus = 0 + if owner.listener.nativeButtonFocus.Load() && !owner.ocrReview { + owner.device.nativeButtonFocus = 1 + } + owner.listener.nativeButtonApplied.Store(owner.device.nativeButtonFocus != 0) + return true +} + +func (owner *commandOwner) publishLocks(key C.CommandKey) { + for index, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} { + mask := C.int(1 << index) + if (key.locks^owner.lastLocks)&mask != 0 { + owner.listener.publish_lock_state(input.LockState{Name: name, Locked: key.locks&mask != 0, Timestamp: uint32(key.timestamp)}) + } + } + owner.lastLocks = key.locks +} diff --git a/internal/platform/xorg/commands_security.go b/internal/platform/xorg/commands_security.go new file mode 100644 index 0000000..173b92d --- /dev/null +++ b/internal/platform/xorg/commands_security.go @@ -0,0 +1,54 @@ +package xorg + +import ( + "errors" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func (listener *CommandListener) SetSecureInputActive(active bool) { + listener.secureMutex.Lock() + defer listener.secureMutex.Unlock() + listener.secure.Store(active) + if active { + listener.browse.Store(false) + listener.nativeButtonFocus.Store(false) + } + for { + select { + case <-listener.commands: + case <-listener.lockStates: + default: + return + } + } +} + +func (listener *CommandListener) publish_lock_state(state input.LockState) { + listener.secureMutex.Lock() + defer listener.secureMutex.Unlock() + if listener.secure.Load() { + return + } + select { + case listener.lockStates <- state: + default: + select { + case listener.failures <- errors.New("X11 lock state queue overflow"): + default: + } + } +} + +func (listener *CommandListener) enqueueSecureCommand(command input.CommandEvent) bool { + listener.secureMutex.Lock() + defer listener.secureMutex.Unlock() + if listener.secure.Load() { + return true + } + select { + case listener.commands <- command: + return true + default: + return false + } +} diff --git a/internal/platform/xorg/commands_ui.go b/internal/platform/xorg/commands_ui.go new file mode 100644 index 0000000..f0547f9 --- /dev/null +++ b/internal/platform/xorg/commands_ui.go @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#cgo pkg-config: xkbcommon +#include "commands_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type uiReply struct { + session uint64 + err error +} +type uiUpdate struct { + ctx context.Context + closeSession uint64 + result chan uiReply +} + +func (listener *CommandListener) InternalUISession() uint64 { return listener.uiSession.Load() } +func (listener *CommandListener) InternalUIActive() bool { return listener.uiSession.Load() != 0 } +func (listener *CommandListener) InternalUIEchoActive() bool { return listener.uiEcho.Load() } + +// BeginInternalUI acknowledges exclusive capture without changing X input +// focus. EndInternalUI waits for physical releases, and cannot close a newer UI. +func (listener *CommandListener) BeginInternalUI(ctx context.Context) (uint64, error) { + return listener.update_internal_ui(ctx, 0) +} +func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint64) error { + if session == 0 { + return errors.New("missing internal UI session") + } + // Revoke promptly even when the acknowledgement must wait for a held key. + if session > listener.uiSequence.Load() { + return errors.New("unknown internal UI session") + } + for previous := listener.uiClose.Load(); previous < session; previous = listener.uiClose.Load() { + if listener.uiClose.CompareAndSwap(previous, session) { + break + } + } + _, err := listener.update_internal_ui(ctx, session) + return err +} +func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64) (uint64, error) { + listener.mutex.Lock() + done := listener.done + listener.mutex.Unlock() + if done == nil { + return 0, errors.New("command listener is not running") + } + update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1)} + select { + case listener.uiUpdates <- update: + case <-ctx.Done(): + return 0, ctx.Err() + case <-done: + return 0, errors.New("command listener stopped") + } + select { + case result := <-update.result: + return result.session, result.err + case <-done: + return 0, errors.New("command listener stopped") + } +} + +func (owner *commandOwner) begin_internal_ui() (uint64, error) { + if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 { + return 0, errors.New("internal UI input unavailable") + } + var revert C.int + C.XGetInputFocus(owner.device.display, &owner.uiFocus, &revert) + if owner.uiFocus == 0 || owner.uiFocus == 1 { + return 0, errors.New("no focused application") + } + owner.uiWindow = C.read_foreground(owner.device.display).window + if status := C.acquire_internal_keyboard(owner.device); status != 0 { + return 0, fmt.Errorf("internal keyboard capture failed: %d", int(status)) + } + owner.uiSession = owner.listener.uiSequence.Add(1) + owner.listener.uiSession.Store(owner.uiSession) + owner.listener.uiEcho.Store(true) + owner.listener.grabActive.Store(true) + return owner.uiSession, nil +} + +func (owner *commandOwner) dismiss_internal_ui() { + owner.uiSession = 0 + owner.listener.uiSession.Store(0) + C.reset_internal_compose(owner.device) + if len(owner.held) == 0 { + C.release_command_keyboard(owner.device) + owner.listener.uiEcho.Store(false) + owner.listener.grabActive.Store(false) + } +} +func (owner *commandOwner) report_ui_failure(err error) bool { + owner.listener.failures <- err + return false +} +func (owner *commandOwner) sync_internal_ui() bool { + if owner.uiSession != 0 { + var focus C.Window + var revert C.int + C.XGetInputFocus(owner.device.display, &focus, &revert) + requestedClose := owner.listener.uiClose.Load() == owner.uiSession + if requestedClose || !owner.commandGrabsAllowed() || focus != owner.uiFocus || C.read_foreground(owner.device.display).window != owner.uiWindow { + session := owner.uiSession + owner.dismiss_internal_ui() + // Retain ownership of the current chord until its releases arrive. + owner.bound = len(owner.held) != 0 + if !requestedClose && !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: input.InternalUIDismiss, UISession: session}) { + return owner.report_ui_failure(errors.New("internal UI queue overflow")) + } + } + } + if len(owner.held) == 0 { + select { + case update := <-owner.listener.uiUpdates: + result := uiReply{err: update.ctx.Err()} + if result.err == nil { + if update.closeSession != 0 { + if update.closeSession == owner.uiSession { + owner.dismiss_internal_ui() + } + } else { + result.session, result.err = owner.begin_internal_ui() + } + } + update.result <- result + default: + } + } + return true +} + +func (owner *commandOwner) handle_internal_key(key C.CommandKey) bool { + owner.bound = true + owner.router.Handle(uint32(key.symbol), uint16(key.code), uint16(key.modifiers), key.pressed != 0) + if key.pressed == 0 { + return true + } + symbol := uint32(key.symbol) + if key.textSymbol != 0 { + symbol = uint32(key.textSymbol) + } + chord := owner.router.Chord(symbol, uint16(key.modifiers)) + // Reader modifiers and their initiating chord never become field contents. + if owner.router.IsModifier(uint32(key.symbol)) { + return true + } + if chord.CthulhuModifier && uint32(key.symbol) != 0xff1b { + return true + } + event := input.CommandEvent{Command: input.InternalUIKey, UISession: owner.uiSession, Timestamp: uint32(key.timestamp), Chord: chord, Text: C.GoString(&key.text[0])} + if uint32(key.symbol) == 0xff1b { + owner.dismiss_internal_ui() + } + if !owner.listener.enqueueSecureCommand(event) { + return owner.report_ui_failure(errors.New("internal UI queue overflow")) + } + return true +} diff --git a/internal/platform/xorg/listener.go b/internal/platform/xorg/listener.go new file mode 100644 index 0000000..8520dd6 --- /dev/null +++ b/internal/platform/xorg/listener.go @@ -0,0 +1,399 @@ +package xorg + +/* +#cgo pkg-config: x11 xtst +#include +#include +#include +#include +#include +#include + +void cthulhuRecordKeyEvent(uintptr_t closure, int pressed, int keycode, uint32_t timestamp); + +static void cthulhuRecordCallback(XPointer closure, XRecordInterceptData *data) { + if (data->category != XRecordFromServer || data->data_len < 2) { + XRecordFreeData(data); + return; + } + + const unsigned char *event = (const unsigned char *)data->data; + if (event[0] == KeyPress || event[0] == KeyRelease) { + uint32_t timestamp; + memcpy(×tamp, event + 4, sizeof(timestamp)); + if (data->client_swapped) timestamp = __builtin_bswap32(timestamp); + cthulhuRecordKeyEvent((uintptr_t)closure, event[0] == KeyPress, event[1], timestamp); + } + XRecordFreeData(data); +} + +static unsigned char *cthulhuModifierKeycodes(Display *display) { + unsigned char *modifiers = calloc(256, sizeof(unsigned char)); + if (modifiers == NULL) { + return NULL; + } + int minKeycode = 0; + int maxKeycode = 0; + XDisplayKeycodes(display, &minKeycode, &maxKeycode); + + for (int keycode = minKeycode; keycode <= maxKeycode; keycode++) { + int symbolCount = 0; + KeySym *symbols = XGetKeyboardMapping(display, keycode, 1, &symbolCount); + if (symbols == NULL) { + continue; + } + for (int index = 0; index < symbolCount; index++) { + KeySym symbol = symbols[index]; + if (symbol == XK_Control_L || symbol == XK_Control_R || + symbol == XK_Shift_L || symbol == XK_Shift_R) { + modifiers[keycode] = 1; + } + } + XFree(symbols); + } + return modifiers; +} + +static char **cthulhuKeyNames(Display *display) { + char **names = calloc(256, sizeof(char *)); + if (names == NULL) { + return NULL; + } + int minKeycode = 0; + int maxKeycode = 0; + XDisplayKeycodes(display, &minKeycode, &maxKeycode); + int symbolsPerKeycode = 0; + KeySym *symbols = XGetKeyboardMapping( + display, minKeycode, maxKeycode - minKeycode + 1, &symbolsPerKeycode); + if (symbols == NULL) { + free(names); + return NULL; + } + for (int keycode = minKeycode; keycode <= maxKeycode; keycode++) { + int offset = (keycode - minKeycode) * symbolsPerKeycode; + for (int index = 0; index < symbolsPerKeycode; index++) { + KeySym symbol = symbols[offset + index]; + if (symbol == NoSymbol) { + continue; + } + const char *name = XKeysymToString(symbol); + if (name != NULL) { + names[keycode] = strdup(name); + break; + } + } + } + XFree(symbols); + return names; +} + +static XRecordRange *cthulhuAllocKeyRange(Display *display) { + XRecordRange *range = XRecordAllocRange(); + if (range != NULL) { + range->device_events.first = KeyPress; + range->device_events.last = KeyRelease; + } + return range; +} + +static XRecordContext cthulhuCreateKeyContext(Display *display, XRecordRange *range) { + XRecordClientSpec client = XRecordAllClients; + return XRecordCreateContext(display, 0, &client, 1, &range, 1); +} + +static XPointer cthulhuClosure(uintptr_t closure) { + return (XPointer)(uintptr_t)closure; +} + +static XRecordInterceptProc cthulhuRecordProc() { + return cthulhuRecordCallback; +} +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +const eventQueueCapacity = 32 + +type Listener struct { + secureMutex sync.Mutex + secure atomic.Bool + intent input.IntentTracker + keyRepeats input.RepeatTracker // XRecord owner, under secureMutex. + controlDisplay *C.Display + recordDisplay *C.Display + recordContext C.XRecordContext + enableDone chan int + stopDone chan error + events chan input.Event + modifierCodes input.ModifierKeycodes + keyNames [256]string + overflow atomic.Uint64 + started bool + stopping bool + stopped bool + stopError error + registryID uint64 + mutex sync.Mutex +} + +var listenerRegistry = struct { + mutex sync.Mutex + nextID uint64 + listeners map[uint64]*Listener +}{listeners: make(map[uint64]*Listener)} + +func New() *Listener { + return &Listener{events: make(chan input.Event, eventQueueCapacity)} +} + +func (listener *Listener) Start(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + listener.mutex.Lock() + if listener.started { + listener.mutex.Unlock() + return errors.New("X11 listener already started") + } + listener.mutex.Unlock() + + controlDisplay := C.XOpenDisplay(nil) + if controlDisplay == nil { + return errors.New("X11 listener startup: no display") + } + recordDisplay := C.XOpenDisplay(nil) + if recordDisplay == nil { + C.XCloseDisplay(controlDisplay) + return errors.New("X11 listener startup: no record display") + } + + var major, minor C.int + if C.XRecordQueryVersion(controlDisplay, &major, &minor) == 0 { + C.XCloseDisplay(recordDisplay) + C.XCloseDisplay(controlDisplay) + return errors.New("X11 listener startup: XRecord unavailable") + } + + keyRange := C.cthulhuAllocKeyRange(controlDisplay) + if keyRange == nil { + C.XCloseDisplay(recordDisplay) + C.XCloseDisplay(controlDisplay) + return errors.New("X11 listener startup: allocate record range") + } + recordContext := C.cthulhuCreateKeyContext(controlDisplay, keyRange) + C.XFree(unsafe.Pointer(keyRange)) + if recordContext == 0 { + C.XCloseDisplay(recordDisplay) + C.XCloseDisplay(controlDisplay) + return errors.New("X11 listener startup: create record context") + } + + listenerRegistry.mutex.Lock() + listenerRegistry.nextID++ + listener.registryID = listenerRegistry.nextID + listenerRegistry.listeners[listener.registryID] = listener + listenerRegistry.mutex.Unlock() + + listener.mutex.Lock() + listener.controlDisplay = controlDisplay + listener.recordDisplay = recordDisplay + listener.recordContext = recordContext + modifierCodes := C.cthulhuModifierKeycodes(controlDisplay) + if modifierCodes == nil { + listener.mutex.Unlock() + listenerRegistry.mutex.Lock() + delete(listenerRegistry.listeners, listener.registryID) + listenerRegistry.mutex.Unlock() + C.XRecordFreeContext(controlDisplay, recordContext) + C.XCloseDisplay(recordDisplay) + C.XCloseDisplay(controlDisplay) + return errors.New("X11 listener startup: allocate modifier keycodes") + } + keyNames := C.cthulhuKeyNames(controlDisplay) + if keyNames == nil { + C.free(unsafe.Pointer(modifierCodes)) + listener.mutex.Unlock() + listenerRegistry.mutex.Lock() + delete(listenerRegistry.listeners, listener.registryID) + listenerRegistry.mutex.Unlock() + C.XRecordFreeContext(controlDisplay, recordContext) + C.XCloseDisplay(recordDisplay) + C.XCloseDisplay(controlDisplay) + return errors.New("X11 listener startup: allocate key names") + } + for keycode := range listener.modifierCodes { + listener.modifierCodes[keycode] = (*[256]C.uchar)(unsafe.Pointer(modifierCodes))[keycode] != 0 + keyName := (*[256]*C.char)(unsafe.Pointer(keyNames))[keycode] + if keyName != nil { + listener.keyNames[keycode] = C.GoString(keyName) + C.free(unsafe.Pointer(keyName)) + } + } + C.free(unsafe.Pointer(modifierCodes)) + C.free(unsafe.Pointer(keyNames)) + listener.enableDone = make(chan int, 1) + listener.started = true + listener.stopped = false + listener.mutex.Unlock() + + closure := C.cthulhuClosure(C.uintptr_t(uintptr(listener.registryID))) + go func() { + runtime.LockOSThread() + status := C.XRecordEnableContext( + recordDisplay, + recordContext, + C.cthulhuRecordProc(), + closure, + ) + listener.enableDone <- int(status) + }() + return nil +} + +func (listener *Listener) Stop(ctx context.Context) error { + listener.mutex.Lock() + if !listener.started { + listener.mutex.Unlock() + return nil + } + if listener.stopped { + stopError := listener.stopError + listener.mutex.Unlock() + return stopError + } + if listener.stopping { + stopDone := listener.stopDone + listener.mutex.Unlock() + select { + case stopError := <-stopDone: + return stopError + case <-ctx.Done(): + return fmt.Errorf("wait for X11 listener stop: %w", ctx.Err()) + } + } + controlDisplay := listener.controlDisplay + recordDisplay := listener.recordDisplay + recordContext := listener.recordContext + enableDone := listener.enableDone + listener.stopping = true + stopDone := make(chan error, 1) + listener.stopDone = stopDone + listener.mutex.Unlock() + + go listener.stop(controlDisplay, recordDisplay, recordContext, enableDone, stopDone) + + select { + case stopError := <-stopDone: + return stopError + case <-ctx.Done(): + return fmt.Errorf("wait for X11 listener stop: %w", ctx.Err()) + } +} + +func (listener *Listener) stop( + controlDisplay *C.Display, + recordDisplay *C.Display, + recordContext C.XRecordContext, + enableDone <-chan int, + stopDone chan<- error, +) { + var stopError error + if C.XRecordDisableContext(controlDisplay, recordContext) == 0 { + stopError = errors.New("X11 listener stop: disable record context") + } + C.XSync(controlDisplay, 0) + + select { + case status := <-enableDone: + if status == 0 { + stopError = errors.Join(stopError, errors.New("X11 listener stop: record loop exited with failure")) + } + } + + if C.XRecordFreeContext(controlDisplay, recordContext) == 0 { + stopError = errors.Join(stopError, errors.New("X11 listener stop: free record context")) + } + C.XCloseDisplay(recordDisplay) + C.XCloseDisplay(controlDisplay) + + listenerRegistry.mutex.Lock() + delete(listenerRegistry.listeners, listener.registryID) + listenerRegistry.mutex.Unlock() + + listener.mutex.Lock() + listener.stopped = true + listener.stopError = stopError + listener.mutex.Unlock() + stopDone <- stopError +} + +func (listener *Listener) RecentInput() input.Activity { return listener.intent.RecentInput() } + +func (listener *Listener) RecentIntent() input.Intent { return listener.intent.RecentIntent() } + +func (listener *Listener) Events() <-chan input.Event { + return listener.events +} + +func (listener *Listener) ModifierKeycodes() input.ModifierKeycodes { + listener.mutex.Lock() + defer listener.mutex.Unlock() + return listener.modifierCodes +} + +func (listener *Listener) Overflow() uint64 { + return listener.overflow.Load() +} + +//export cthulhuRecordKeyEvent +func cthulhuRecordKeyEvent(closure uintptr, pressed C.int, keycode C.int, timestamp C.uint32_t) { + listenerRegistry.mutex.Lock() + listener := listenerRegistry.listeners[uint64(closure)] + listenerRegistry.mutex.Unlock() + if listener == nil { + return + } + listener.secureMutex.Lock() + defer listener.secureMutex.Unlock() + if listener.secure.Load() { + return + } + + eventKeycode := uint8(keycode) + event := input.NewKeyEvent(eventKeycode, pressed != 0, listener.keyNames[eventKeycode]) + event.Timestamp = uint32(timestamp) + event.Repeat = listener.keyRepeats.Observe(event) + listener.intent.Observe(event) + select { + case listener.events <- event: + default: + listener.overflow.Add(1) + } +} + +func (listener *Listener) SetSecureInputActive(active bool) { + listener.secureMutex.Lock() + defer listener.secureMutex.Unlock() + listener.secure.Store(active) + listener.intent.Reset() + listener.keyRepeats.Reset() + for { + select { + case <-listener.events: + default: + return + } + } +} diff --git a/internal/platform/xorg/listener_native_test.go b/internal/platform/xorg/listener_native_test.go new file mode 100644 index 0000000..3be5438 --- /dev/null +++ b/internal/platform/xorg/listener_native_test.go @@ -0,0 +1,1133 @@ +package xorg + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeModifierPressRelease(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + for range 2 { + listener := New() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := listener.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + // Allow the asynchronous XRecord enable request to reach the server. + time.Sleep(50 * time.Millisecond) + if err := exec.Command("xdotool", "key", "Control_L", "Shift_L", "q").Run(); err != nil { + t.Fatal(err) + } + presses, releases := 0, 0 + seenNames := map[string]bool{} + for presses < 2 || releases < 2 { + select { + case event := <-listener.Events(): + if listener.ModifierKeycodes().Contains(event.Keycode) { + seenNames[event.Name] = true + if event.Pressed { + presses++ + } else { + releases++ + } + } + case <-ctx.Done(): + t.Fatal("lost Control/Shift press or release") + } + } + if listener.Overflow() != 0 { + t.Fatal("unexpected native overflow") + } + if !seenNames["Control_L"] || !seenNames["Shift_L"] { + t.Fatalf("normalized modifier names = %v", seenNames) + } + if err := exec.Command("xdotool", "key", "F1", "Tab", "Left").Run(); err != nil { + t.Fatal(err) + } + seenKinds := map[string]input.KeyKind{} + for len(seenKinds) < 3 { + select { + case event := <-listener.Events(): + if event.Pressed && (event.Name == "F1" || event.Name == "Tab" || event.Name == "Left") { + seenKinds[event.Text] = event.Kind + } + case <-ctx.Done(): + t.Fatalf("lost named non-printable key events: %v", seenKinds) + } + } + if seenKinds["f1"] != input.KeyFunction || seenKinds["tab"] != input.KeyAction || seenKinds["left"] != input.KeyNavigation { + t.Fatalf("normalized non-printable keys = %v", seenKinds) + } + // The events above establish XRecord readiness. Exercise server + // autorepeat, then a real release/new press of the same movement key. + waitUp := func(pressed bool) input.Event { + t.Helper() + for { + select { + case event := <-listener.Events(): + if event.Name == "Up" && event.Pressed == pressed { + return event + } + case <-ctx.Done(): + t.Fatal("lost movement key event") + } + } + } + if err := exec.CommandContext(ctx, "xdotool", "keydown", "Up").Run(); err != nil { + t.Fatal(err) + } + if waitUp(true).Repeat || !waitUp(true).Repeat { + t.Fatal("held-key repeat was not distinguished from first press") + } + injected := make(chan error, 1) + go func() { + if err := exec.CommandContext(ctx, "xdotool", "keyup", "Up").Run(); err != nil { + injected <- err + return + } + injected <- exec.CommandContext(ctx, "xdotool", "key", "F2").Run() + }() + // Drain past the release using an ordered marker. A classic repeat's + // release may already be queued before the actual key-up. + released := false + for !released { + select { + case event := <-listener.Events(): + if event.Name == "F2" && event.Pressed { + released = true + } + case <-ctx.Done(): + t.Fatal("lost key release marker") + } + } + if err := <-injected; err != nil { + t.Fatal(err) + } + if err := exec.CommandContext(ctx, "xdotool", "key", "Up").Run(); err != nil { + t.Fatal(err) + } + if waitUp(true).Repeat { + t.Fatal("fresh movement press classified as repeat") + } + if err := listener.Stop(ctx); err != nil { + t.Fatal(err) + } + if err := listener.Stop(ctx); err != nil { + t.Fatal(err) + } + cancel() + } +} + +func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(name string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", name).CombinedOutput(); err != nil { + t.Fatalf("send %s: %v %s", name, err, out) + } + } + want := func(command input.Command) input.CommandEvent { + t.Helper() + select { + case event := <-listener.Commands(): + if event.Command != command { + t.Fatalf("got %+v, want %s", event, command) + } + return event + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + return input.CommandEvent{} + } + for _, opener := range []string{"Insert+h", "Insert+F1"} { + key(opener) + want(input.ToggleLearnMode) + if !listener.LearnModeActive() || !listener.CommandGrabActive() { + t.Fatal("learn mode released keyboard after opening chord") + } + key("q") + if event := want(input.LearnKey); event.KeyName != "q" || event.Chord.Symbol != 'q' { + t.Fatalf("learn key = %+v", event) + } + key("Insert+q") + want(input.LearnKey) + if event := want(input.LearnKey); event.Chord.Symbol != 'q' || !event.Chord.CthulhuModifier { + t.Fatalf("learned quit chord = %+v", event) + } + key("F1") + want(input.LearnDocumentation) + if listener.LearnModeActive() { + t.Fatal("F1 did not release learn mode") + } + } + key("Insert+h") + want(input.ToggleLearnMode) + key("F2") + want(input.LearnDefaultShortcuts) + key("Insert+F1") + want(input.ToggleLearnMode) + key("F3") + want(input.LearnApplicationShortcuts) + key("Insert+h") + want(input.ToggleLearnMode) + key("Escape") + want(input.ToggleLearnMode) + key("Insert+h") + want(input.ToggleLearnMode) + key("Insert+F1") + want(input.LearnKey) // The modifier itself is also named in learn mode. + want(input.ToggleLearnMode) + if listener.LearnModeActive() || listener.CommandGrabActive() { + t.Fatal("exit did not release keyboard") + } + key("Insert+F1") + want(input.ToggleLearnMode) + key("Insert+h") + want(input.LearnKey) + want(input.ToggleLearnMode) + if listener.LearnModeActive() { + t.Fatal("Cthulhu H did not exit learn mode") + } + key("Insert+q") + want(input.Quit) + key("Insert+h") + want(input.ToggleLearnMode) + listener.SetSecureInputActive(true) + for listener.LearnModeActive() || listener.CommandGrabActive() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal("secure mode retained learn keyboard grab") + } + } + for listener.commandsApplied.Load() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal("secure mode retained command grabs") + } + } + listener.SetSecureInputActive(false) + for !listener.CommandGrabsEnabled() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal("unlock did not restore command grabs") + } + } + key("Insert+F1") + if event := want(input.ToggleLearnMode); !event.LearnModeStarted { + t.Fatal("learn mode did not restart after secure mode") + } + key("Escape") + want(input.ToggleLearnMode) +} + +func TestNativeLearnModeKeepsKeysFromFocusedApplication(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + logfile, err := os.CreateTemp(t.TempDir(), "xev-keys-*") + if err != nil { + t.Fatal(err) + } + defer logfile.Close() + probe := exec.CommandContext(ctx, "xev", "-name", "Cthulhu Learn Capture", "-event", "keyboard") + probe.Stdout = logfile + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + window := "" + for window == "" { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Learn Capture$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + if ctx.Err() != nil { + t.Fatal(ctx.Err()) + } + time.Sleep(10 * time.Millisecond) + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + for _, name := range []string{"Insert+h", "z", "Escape", "y"} { + if output, err := exec.CommandContext(ctx, "xdotool", "key", name).CombinedOutput(); err != nil { + t.Fatalf("send %s: %v %s", name, err, output) + } + } + if err := probe.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = probe.Wait() + contents, err := os.ReadFile(logfile.Name()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), "keysym 0x7a, z") || !strings.Contains(string(contents), "keysym 0x79, y") { + t.Fatalf("learned z leaked or ordinary y failed to reach application: %s", contents) + } +} + +func TestNativeBrowseKeyTransitions(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser X server") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + ready := func(want bool) { + t.Helper() + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for listener.browseApplied.Load() != want { + select { + case <-tick.C: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + command := func(want input.Command) { + t.Helper() + select { + case got := <-listener.Commands(): + if got.Command != want { + t.Fatalf("command %s want %s", got.Command, want) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + listener.SetBrowseNavigation(true) + ready(true) + for _, tc := range []struct { + letter string + want input.Command + }{ + {"h", input.DocumentNextHeading}, + {"k", input.DocumentNextLink}, + {"b", input.DocumentNextButton}, + {"x", input.DocumentNextCheckBox}, + {"l", input.DocumentNextList}, + {"s", input.DocumentNextSeparator}, + {"q", input.DocumentNextBlockQuote}, + {"c", input.DocumentNextComboBox}, + {"e", input.DocumentNextEntry}, + {"f", input.DocumentNextFormField}, + {"g", input.DocumentNextImage}, + {"m", input.DocumentNextLandmark}, + {"i", input.DocumentNextListItem}, + {"d", input.DocumentNextLiveRegion}, + {"p", input.DocumentNextParagraph}, + {"r", input.DocumentNextRadioButton}, + {"t", input.DocumentNextTable}, + {"u", input.DocumentNextUnvisitedLink}, + {"v", input.DocumentNextVisitedLink}, + {"a", input.DocumentNextClickable}, + {"o", input.DocumentNextChunk}, + } { + key("keydown", tc.letter) + command(tc.want) + key("keyup", tc.letter) + } + key("key", "shift+x") + command(input.DocumentPreviousCheckBox) + for _, tc := range []struct { + key string + want input.Command + }{ + {"Down", input.DocumentCaretNextLine}, + {"Up", input.DocumentCaretPreviousLine}, + {"Right", input.DocumentCaretNextCharacter}, + {"Left", input.DocumentCaretPreviousCharacter}, + {"ctrl+Right", input.DocumentCaretNextWord}, + {"ctrl+Left", input.DocumentCaretPreviousWord}, + {"Home", input.DocumentCaretLineStart}, + {"End", input.DocumentCaretLineEnd}, + {"ctrl+Home", input.DocumentCaretDocumentStart}, + {"ctrl+End", input.DocumentCaretDocumentEnd}, + } { + key("key", tc.key) + command(tc.want) + } + for _, level := range []int{1, 2, 3, 4, 5, 6} { + key("key", fmt.Sprintf("%d", level)) + command(input.Command(fmt.Sprintf("document-next-heading-at-level-%d", level))) + key("key", fmt.Sprintf("shift+%d", level)) + command(input.Command(fmt.Sprintf("document-previous-heading-at-level-%d", level))) + } + key("key", "Return") + command(input.DocumentActivate) + listener.SetNativeButtonFocus(true) + for !listener.nativeButtonApplied.Load() { + select { + case <-ctx.Done(): + t.Fatal("button focus policy was not applied") + case <-time.After(time.Millisecond): + } + } + key("key", "Return") + select { + case got := <-listener.Commands(): + t.Fatalf("native button Return was consumed as %s", got.Command) + case <-time.After(100 * time.Millisecond): + } + listener.SetNativeButtonFocus(false) + for listener.nativeButtonApplied.Load() { + select { + case <-ctx.Done(): + t.Fatal("button focus policy was not cleared") + case <-time.After(time.Millisecond): + } + } + key("key", "Return") + command(input.DocumentActivate) + key("keydown", "h") + command(input.DocumentNextHeading) + // The press has reached X11 before changing policy. Its release must remain + // owned by the original chord even though the next h will pass through. + listener.SetBrowseNavigation(false) + key("keyup", "h") + ready(false) + listener.SetBrowseNavigation(true) + ready(true) + key("key", "Num_Lock") + key("key", "shift+h") + command(input.DocumentPreviousHeading) + key("key", "Num_Lock") + key("keydown", "Insert", "keydown", "a", "keyup", "Insert", "keyup", "a") + command(input.ToggleDocumentMode) + key("keydown", "Insert", "keydown", "ctrl", "keydown", "space", "keyup", "space", "keyup", "ctrl", "keyup", "Insert") + command(input.ApplicationPreferences) + key("key", "Insert+shift+space") + command(input.ApplicationPreferences) + key("key", "Insert+ctrl+shift+space") + command(input.ChooseApplicationPreferences) + key("key", "h") + command(input.DocumentNextHeading) + // Chromium can move raw X input focus into a child while its foreground + // client remains the same. Browse arrows must remain owned across that + // transition; an exact XGetInputFocus comparison loses every later arrow. + parentOutput, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output() + if err != nil { + t.Fatal(err) + } + parent := strings.TrimSpace(string(parentOutput)) + childFocus := exec.CommandContext(ctx, "python3", "tests/x11_focus_child.py", parent) + childFocus.Dir = "../../.." + childInput, err := childFocus.StdinPipe() + if err != nil { + t.Fatal(err) + } + childOutput, err := childFocus.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := childFocus.Start(); err != nil { + t.Fatal(err) + } + defer childFocus.Process.Kill() + childID := bufio.NewScanner(childOutput) + if !childID.Scan() { + t.Fatalf("child focus fixture did not start: %v", childID.Err()) + } + if got, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output(); err != nil || + strings.TrimSpace(string(got)) != childID.Text() { + t.Fatalf("X input focus did not move to child: %q, %v", got, err) + } + key("key", "Down") + command(input.DocumentCaretNextLine) + // Native focus keys must still reach the application after consecutive + // browse arrows; Steam uses Tab and Escape for its own navigation. + key("key", "Up") + command(input.DocumentCaretPreviousLine) + key("key", "Down") + command(input.DocumentCaretNextLine) + for listener.grabActive.Load() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal("browse keyboard grab was not released") + } + } + events, err := os.CreateTemp(t.TempDir(), "child-keys-*.log") + if err != nil { + t.Fatal(err) + } + defer events.Close() + xev := exec.CommandContext(ctx, "stdbuf", "-oL", "xev", "-id", childID.Text(), "-event", "keyboard") + xev.Stdout = events + if err := xev.Start(); err != nil { + t.Fatal(err) + } + defer xev.Process.Kill() + time.Sleep(50 * time.Millisecond) + key("key", "Tab") + key("key", "Escape") + for { + contents, err := os.ReadFile(events.Name()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), "keysym 0xff09, Tab") && + strings.Contains(string(contents), "keysym 0xff1b, Escape") { + break + } + select { + case <-time.After(10 * time.Millisecond): + case <-ctx.Done(): + t.Fatalf("Tab/Escape did not reach the focused child after browse arrows: %s", contents) + } + } + _ = xev.Process.Kill() + _ = xev.Wait() + if _, err := childInput.Write([]byte("\n")); err != nil { + t.Fatal(err) + } + if err := childFocus.Wait(); err != nil { + t.Fatal(err) + } + + // Browse grabs are updated asynchronously after focus changes. Every + // passively grabbed browse key must be replayed if it arrives while the + // recorded browser window is no longer focused. + xterm := exec.CommandContext(ctx, "xterm", "-T", "Cthulhu Browse Replay", "-e", "bash", "-c", "IFS= read -r -n 1 value; test \"$value\" = q") + if err := xterm.Start(); err != nil { + t.Fatal(err) + } + window := "" + for window == "" { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Browse Replay$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(10 * time.Millisecond): + } + } + key("windowfocus", window) + key("key", "q") + if err := xterm.Wait(); err != nil { + t.Fatalf("stale browse grab did not replay q to the new focus: %v", err) + } + if err := listener.Stop(ctx); err != nil { + t.Fatal(err) + } + // Reacquisition proves cleanup released both static and browse grabs. + next := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := next.Start(ctx); err != nil { + t.Fatal(err) + } + defer next.Stop(context.Background()) +} + +func TestNativeCommandGrabSuspensionAndRestoration(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + ready := func(listener *CommandListener, want bool) { + t.Helper() + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for listener.commandsApplied.Load() != want { + select { + case <-ticker.C: + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + + ready(listener, true) + listener.SetCommandGrabs(false) + ready(listener, false) + + reacquire := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := reacquire.Start(ctx); err != nil { + t.Fatalf("suspended listener still owned command grabs: %v", err) + } + if err := reacquire.Stop(context.Background()); err != nil { + t.Fatal(err) + } + + listener.SetCommandGrabs(true) + ready(listener, true) + select { + case err := <-listener.Failures(): + t.Fatal(err) + default: + } +} + +func TestNativeActiveWindowIsXTerm(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + xterm := exec.CommandContext(ctx, "xterm", "-T", "Cthulhu XTerm Match", "-e", "sleep", "3") + if err := xterm.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = xterm.Wait() + }() + var window string + for window == "" { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--name", "^Cthulhu XTerm Match$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(10 * time.Millisecond): + } + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + for !ActiveWindowIsXTerm() { + select { + case <-ctx.Done(): + t.Fatal("active X11 window was not identified as XTerm") + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestNativeDispatchBeforeRelease(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} { + for _, modifierFirst := range []bool{false, true} { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + listener := NewCommands([]string{"Insert"}, layout) + if err := listener.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + defer listener.Stop(context.Background()) + heldKeys := make(map[string]bool) + defer func() { + // A failed press-time assertion must not leave synthetic keys + // held on the display shared by the remaining native tests. + cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), time.Second) + defer cleanupCancel() + for name := range heldKeys { + if out, err := exec.CommandContext(cleanupContext, "xdotool", "keyup", name).CombinedOutput(); err != nil { + t.Errorf("release held %s: %v %s", name, err, out) + } + } + }() + key := func(args ...string) { + t.Helper() + if args[0] == "keydown" { + heldKeys[args[1]] = true + } + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + if args[0] == "keyup" { + delete(heldKeys, args[1]) + } + } + type bindingCase struct { + keys []string + want input.Command + } + cases := []bindingCase{ + {[]string{"Insert", "t"}, input.PresentTime}, + {[]string{"Insert", "t"}, input.PresentDate}, + {[]string{"Insert", "Shift_L", "c"}, input.PresentClipboard}, + {[]string{"Insert", "Control_L", "h"}, input.ShowSpeechHistory}, + {[]string{"Insert", "Shift_L", "d"}, input.ToggleSpeechMonitor}, + {[]string{"Insert", "space"}, input.Preferences}, + {[]string{"Insert", "Control_L", "Up"}, input.ObjectParent}, + {[]string{"Insert", "h"}, input.ToggleLearnMode}, + {[]string{"Insert", "a"}, input.ToggleDocumentMode}, + } + if layout == input.DesktopKeyboardLayout { + cases = append(cases, bindingCase{[]string{"KP_Add"}, input.ReadAll}) + cases = append(cases, bindingCase{[]string{"KP_Enter"}, input.WhereAmI}, bindingCase{[]string{"KP_Up"}, input.ReviewCurrentLine}) + cases = append(cases, + bindingCase{[]string{"KP_Down"}, input.ReviewCurrentCharacter}, + bindingCase{[]string{"KP_Down"}, input.ReviewPhoneticCharacter}, + bindingCase{[]string{"KP_Begin"}, input.ReviewCurrentWord}, + bindingCase{[]string{"KP_Begin"}, input.ReviewSpellWord}, + bindingCase{[]string{"KP_Begin"}, input.ReviewPhoneticWord}) + } else { + cases = append(cases, bindingCase{[]string{"Insert", "semicolon"}, input.ReadAll}) + cases = append(cases, bindingCase{[]string{"Insert", "Return"}, input.WhereAmI}, bindingCase{[]string{"Insert", "i"}, input.ReviewCurrentLine}) + cases = append(cases, + bindingCase{[]string{"Insert", "comma"}, input.ReviewCurrentCharacter}, + bindingCase{[]string{"Insert", "comma"}, input.ReviewPhoneticCharacter}, + bindingCase{[]string{"Insert", "k"}, input.ReviewCurrentWord}, + bindingCase{[]string{"Insert", "k"}, input.ReviewSpellWord}, + bindingCase{[]string{"Insert", "k"}, input.ReviewPhoneticWord}) + } + cases = append(cases, bindingCase{[]string{"Insert", "q"}, input.Quit}) + for _, tc := range cases { + for _, name := range tc.keys { + key("keydown", name) + } + select { + case got := <-listener.Commands(): + if got.Command != tc.want { + t.Fatalf("command = %s, want %s", got.Command, tc.want) + } + if tc.want == input.ToggleLearnMode && !got.LearnModeStarted { + t.Fatal("Cthulhu+H did not start learn mode on press") + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("no press-time %s: %v", tc.want, ctx.Err()) + } + // Cancellation occurs while Quit is still physically held. + if tc.want == input.Quit { + listener.cancel() + } + for i := range tc.keys { + index := len(tc.keys) - 1 - i + if modifierFirst { + index = i + } + key("keyup", tc.keys[index]) + } + if tc.want == input.ToggleLearnMode { + // Leave learn mode before checking ordinary shortcuts again. + key("keydown", "Escape") + select { + case got := <-listener.Commands(): + if got.Command != input.ToggleLearnMode || got.LearnModeStarted { + t.Fatalf("learn mode exit = %+v", got) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("no press-time learn mode exit: %v", ctx.Err()) + } + key("keyup", "Escape") + } + } + if err := listener.Stop(ctx); err != nil { + t.Fatal(err) + } + select { + case got := <-listener.Commands(): + t.Fatalf("release dispatched %s", got.Command) + default: + } + cancel() + } + } +} + +func TestNativeSecureInputDropsKeysAndRestoresGrabs(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + keys := New() + if err := keys.Start(ctx); err != nil { + t.Fatal(err) + } + defer keys.Stop(context.Background()) + commands := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := commands.Start(ctx); err != nil { + t.Fatal(err) + } + defer commands.Stop(context.Background()) + commands.SetBrowseNavigation(true) + key := func(name string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", "key", name).CombinedOutput(); err != nil { + t.Fatalf("key: %v %s", err, out) + } + } + // A received event establishes that the XRecord listener is ready. + time.Sleep(50 * time.Millisecond) + key("F1") + select { + case <-keys.Events(): + case <-ctx.Done(): + t.Fatal("XRecord did not become ready") + } + keys.SetSecureInputActive(true) + commands.SetSecureInputActive(true) + commands.SetCommandGrabs(true) // Simulate foreground polling during lock. + for commands.commandsApplied.Load() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal("secure mode did not release native grabs") + } + } + // Another X11 client must actually be able to take the same passive grabs. + other := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := other.Start(ctx); err != nil { + t.Fatalf("secure listener retained grabs: %v", err) + } + if err := other.Stop(context.Background()); err != nil { + t.Fatal(err) + } + key("h") + key("Return") + select { + case event := <-keys.Events(): + t.Fatalf("secure input was captured: %v", event.Kind) + case <-time.After(100 * time.Millisecond): + } + select { + case <-commands.Commands(): + t.Fatal("command executed during secure input") + default: + } + if keys.RecentInput().Sequence != 0 { + t.Fatal("secure input retained presentation intent") + } + keys.SetSecureInputActive(false) + commands.SetSecureInputActive(false) + for !commands.CommandGrabsEnabled() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal("unlock did not restore grabs") + } + } + key("F1") + select { + case event := <-keys.Events(): + if event.Name != "F1" { + t.Fatalf("unlock replayed stale key %q", event.Name) + } + case <-ctx.Done(): + t.Fatal("unlock did not restore XRecord events") + } +} + +func TestNativeCustomBindingsUpdateAndRelease(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Own a foreground client; browse-only grabs deliberately replay when + // no client owns focus or the foreground window has changed. + probe := exec.CommandContext(ctx, "xev", "-name", "Cthulhu Custom Bindings", "-event", "keyboard") + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + window := "" + for window == "" { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Custom Bindings$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + custom := []input.BindingOverride{ + {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"Cthulhu+Ctrl+Alt+y", "F9"}}, + {Layout: input.DesktopKeyboardLayout, Scope: "browse", Command: input.DocumentNextHeading, Keys: []string{"F6"}}, + } + if err := listener.SetKeyBindings(ctx, custom); err != nil { + t.Fatal(err) + } + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + command := func(want input.Command) { + t.Helper() + select { + case got := <-listener.Commands(): + if got.Command != want { + t.Fatalf("command %s want %s", got.Command, want) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + key("keydown", "Insert", "keydown", "Control_L", "keydown", "Alt_L", "keydown", "y") + command(input.Preferences) + // Queue a new configuration while Y is held: it must wait for releases. + applied := make(chan error, 1) + go func() { applied <- listener.SetKeyBindings(ctx, nil) }() + select { + case err := <-applied: + t.Fatalf("update completed while keys were held: %v", err) + case <-time.After(40 * time.Millisecond): + } + key("keyup", "Insert", "keyup", "Control_L", "keyup", "Alt_L", "keyup", "y") + if err := <-applied; err != nil { + t.Fatal(err) + } + key("keydown", "Insert", "key", "space", "keyup", "Insert") + command(input.Preferences) + if err := listener.SetKeyBindings(ctx, custom); err != nil { + t.Fatal(err) + } + key("key", "F9") + command(input.Preferences) + listener.SetBrowseNavigation(true) + for !listener.BrowseNavigationEnabled() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + key("key", "F6") + command(input.DocumentNextHeading) + listener.SetBrowseNavigation(false) + for listener.browseApplied.Load() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + key("key", "F6") + key("key", "F9") + command(input.Preferences) // F6 outside browse mode must not dispatch. + competitor := NewCommands([]string{"KP_Insert"}, input.LaptopKeyboardLayout) + if err := competitor.SetKeyBindings(ctx, []input.BindingOverride{{Layout: input.LaptopKeyboardLayout, Scope: "global", Command: input.PresentDate, Keys: []string{"F10"}}}); err != nil { + t.Fatal(err) + } + if err := competitor.Start(ctx); err != nil { + t.Fatal(err) + } + collision := input.CloneBindingOverrides(custom) + collision[0].Keys = []string{"F10"} + if err := listener.SetKeyBindings(ctx, collision); err == nil { + t.Fatal("accepted conflicting native grab") + } + key("key", "F9") + command(input.Preferences) // Native rollback retained the old shortcut. + browseCollision := input.CloneBindingOverrides(custom) + browseCollision[1].Keys = []string{"F10"} + if err := listener.SetKeyBindings(ctx, browseCollision); err == nil { + t.Fatal("accepted conflicting prospective browse grab") + } + key("key", "F9") + command(input.Preferences) + if err := competitor.Stop(ctx); err != nil { + t.Fatal(err) + } + unavailable := input.CloneBindingOverrides(custom) + unavailable[0].Keys = []string{"Cthulhu+F35"} + if err := listener.SetKeyBindings(ctx, unavailable); err == nil { + t.Fatal("accepted unavailable key") + } + key("key", "F9") + command(input.Preferences) // Rejected update preserved the previous router. +} + +func TestNativeModifierConfigurationUpdate(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + command := func(want input.Command) { + t.Helper() + select { + case got := <-listener.Commands(): + if got.Command != want { + t.Fatalf("command %s want %s", got.Command, want) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + lockState := func() string { + t.Helper() + out, err := exec.CommandContext(ctx, "xset", "q").Output() + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(string(out)) + for i, field := range fields { + if field == "LED" && i+2 < len(fields) && fields[i+1] == "mask:" { + return fields[i+2] + } + } + t.Fatalf("missing keyboard LED mask: %s", out) + return "" + } + initialLock := lockState() + key("key", "Caps_Lock") + if lockState() == initialLock { + t.Fatal("ordinary Caps Lock did not toggle before configuration") + } + key("keydown", "Insert", "keydown", "space") + command(input.Preferences) + applied := make(chan error, 1) + go func() { + applied <- listener.SetKeyboardConfiguration(ctx, input.LaptopKeyboardLayout, []string{"Caps_Lock"}, nil) + }() + select { + case err := <-applied: + t.Fatalf("modifier update completed with keys held: %v", err) + case <-time.After(40 * time.Millisecond): + } + key("keyup", "Insert", "keyup", "space") + if err := <-applied; err != nil { + t.Fatal(err) + } + before := lockState() + key("keydown", "Caps_Lock", "keydown", "semicolon", "keyup", "Caps_Lock", "keyup", "semicolon") + command(input.ReadAll) // Laptop bindings became active with the modifier. + key("keydown", "Caps_Lock", "key", "space", "keyup", "Caps_Lock") + command(input.Preferences) + if after := lockState(); after != before { + t.Fatalf("Caps Lock commands changed lock state: %s -> %s", before, after) + } + if before != initialLock { + t.Fatalf("configuring Caps Lock did not clear existing capitalization: %s -> %s", initialLock, before) + } + key("key", "Caps_Lock") + if after := lockState(); after != before { + t.Fatalf("configured Caps Lock alone toggled capitalization: %s -> %s", before, after) + } + key("keydown", "Caps_Lock", "key", "z", "keyup", "Caps_Lock") + if after := lockState(); after != before { + t.Fatalf("unbound Caps Lock chord toggled capitalization: %s -> %s", before, after) + } + competitor := NewCommands([]string{"KP_Insert"}, input.DesktopKeyboardLayout) + if err := competitor.Start(ctx); err != nil { + t.Fatal(err) + } + if err := listener.SetKeyboardConfiguration(ctx, input.DesktopKeyboardLayout, []string{"KP_Insert"}, nil); err == nil { + t.Fatal("accepted conflicting modifier grab") + } + key("keydown", "Caps_Lock", "key", "semicolon", "keyup", "Caps_Lock") + command(input.ReadAll) // Failed update retained both layout and Caps Lock. + if err := competitor.Stop(ctx); err != nil { + t.Fatal(err) + } + if err := listener.SetKeyboardConfiguration(ctx, input.DesktopKeyboardLayout, []string{"Insert", "KP_Insert", "Caps_Lock"}, nil); err != nil { + t.Fatal(err) + } + for _, modifier := range []string{"Insert", "KP_Insert", "Caps_Lock"} { + key("keydown", modifier, "key", "space", "keyup", modifier) + command(input.Preferences) + } + // With every modifier disabled, a shortcut without Cthulhu remains usable. + custom := []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"F9"}}} + if err := listener.SetKeyboardConfiguration(ctx, input.DesktopKeyboardLayout, nil, custom); err != nil { + t.Fatal(err) + } + key("keydown", "Insert", "key", "space", "keyup", "Insert") + key("key", "F9") + command(input.Preferences) + key("key", "Caps_Lock") + if lockState() == before { + t.Fatal("unchecked Caps Lock did not restore normal lock behavior") + } + key("key", "Caps_Lock") + if lockState() != before { + t.Fatal("unchecked Caps Lock did not unlock normally") + } + + if err := listener.Stop(ctx); err != nil { + t.Fatal(err) + } + // An empty modifier set must also be accepted on restart. + empty := NewCommands(nil, input.LaptopKeyboardLayout) + if err := empty.Start(ctx); err != nil { + t.Fatal(err) + } + if err := empty.Stop(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/internal/platform/xorg/locks_native_test.go b/internal/platform/xorg/locks_native_test.go new file mode 100644 index 0000000..595bc3e --- /dev/null +++ b/internal/platform/xorg/locks_native_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeLockStatesAndShiftCapsLock(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + listener := NewCommands([]string{"Caps_Lock"}, input.LaptopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + state := func(name string, locked bool) { + t.Helper() + select { + case event := <-listener.LockStates(): + if event.Name != name || event.Locked != locked || event.Timestamp == 0 { + t.Fatalf("lock state = %+v, want %s %v with timestamp", event, name, locked) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("missing lock state %s %v", name, locked) + } + } + command := func(want input.Command) { + t.Helper() + select { + case event := <-listener.Commands(): + if event.Command != want || event.Timestamp == 0 { + t.Fatalf("command = %+v, want %s with timestamp", event, want) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("missing command %s", want) + } + } + lockMask := func() string { + t.Helper() + out, err := exec.CommandContext(ctx, "xset", "q").Output() + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(string(out)) + for index, field := range fields { + if field == "LED" && index+2 < len(fields) { + return fields[index+2] + } + } + t.Fatal("missing lock state") + return "" + } + initial := lockMask() + // Caps Lock alone and Shift+CapsLock+V must not toggle capitalization. + key("key", "Caps_Lock") + key("keydown", "Shift_L", "keydown", "Caps_Lock", "key", "v", "keyup", "Shift_L", "keyup", "Caps_Lock") + command(input.PresentVersion) + if lockMask() != initial { + t.Fatal("version chord changed capitalization") + } + for _, shift := range []string{"Shift_L", "Shift_R"} { + key("keydown", shift, "key", "Caps_Lock", "keyup", shift) + state("Caps Lock", true) + if lockMask() == initial { + t.Fatal("Shift+CapsLock did not enable capitalization") + } + // The reader modifier and commands still work while capitalization is on. + key("keydown", "Caps_Lock", "key", "t", "keyup", "Caps_Lock") + command(input.PresentTime) + key("keydown", "Caps_Lock", "keydown", shift, "key", "v", "keyup", "Caps_Lock", "keyup", shift) + command(input.PresentVersion) + if lockMask() == initial { + t.Fatal("reader chord cleared capitalization") + } + // Reverse modifier order and release Shift first. + key("keydown", "Caps_Lock", "keydown", shift, "keyup", shift, "keyup", "Caps_Lock") + state("Caps Lock", false) + if lockMask() != initial { + t.Fatal("Shift+CapsLock did not disable capitalization") + } + } + key("key", "Num_Lock") + state("Num Lock", true) + key("key", "Num_Lock") + state("Num Lock", false) + // A default keymap may leave Scroll Lock without a lock action. Exercise + // the XKB indicator itself to verify its actual state notifications. + for _, enabled := range []bool{true, false} { + option := "led" + if !enabled { + option = "-led" + } + if out, err := exec.CommandContext(ctx, "xset", option, "named", "Scroll Lock").CombinedOutput(); err != nil { + t.Fatalf("Scroll Lock indicator: %v %s", err, out) + } + state("Scroll Lock", enabled) + } + if err := listener.SetKeyboardConfiguration(ctx, input.LaptopKeyboardLayout, nil, nil); err != nil { + t.Fatal(err) + } + key("key", "Caps_Lock") + state("Caps Lock", true) + key("key", "Caps_Lock") + state("Caps Lock", false) +} diff --git a/internal/platform/xorg/ocr_click.c b/internal/platform/xorg/ocr_click.c new file mode 100644 index 0000000..8199e53 --- /dev/null +++ b/internal/platform/xorg/ocr_click.c @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "commands_native.h" +#include + +int cthulhu_ocr_click(unsigned long foreground, int x, int y, int root_width, int root_height) { + Display *display = XOpenDisplay(NULL); + if (!display) return 0; + Window root = DefaultRootWindow(display), query_root, child; + XWindowAttributes attributes; + int pointer_x, pointer_y, window_x, window_y; + unsigned int mask; + int event_base, error_base, major, minor; + int ok = XGetWindowAttributes(display, root, &attributes) && + attributes.width == root_width && attributes.height == root_height && + x >= 0 && x < root_width && y >= 0 && y < root_height && + XTestQueryExtension(display, &event_base, &error_base, &major, &minor) && + XQueryPointer(display, root, &query_root, &child, &pointer_x, &pointer_y, + &window_x, &window_y, &mask) && + read_foreground(display).window == foreground; + int moved = 0; + if (ok) { + cthulhu_x11_begin_read(display); + moved = XTestFakeMotionEvent(display, -1, x, y, CurrentTime); + ok = moved; + XSync(display, False); + if (cthulhu_x11_end_read(display)) ok = 0; + } + if (ok && read_foreground(display).window == foreground) { + cthulhu_x11_begin_read(display); + int pressed = XTestFakeButtonEvent(display, 1, True, CurrentTime); + XSync(display, False); + int released = !pressed || XTestFakeButtonEvent(display, 1, False, CurrentTime); + XTestFakeMotionEvent(display, -1, pointer_x, pointer_y, CurrentTime); + XSync(display, False); + int native_error = cthulhu_x11_end_read(display); + ok = pressed && released && !native_error; + } else { + ok = 0; + if (moved) { + cthulhu_x11_begin_read(display); + XTestFakeMotionEvent(display, -1, pointer_x, pointer_y, CurrentTime); + cthulhu_x11_end_read(display); + } + } + XCloseDisplay(display); + return ok; +} diff --git a/internal/platform/xorg/ocr_click.go b/internal/platform/xorg/ocr_click.go new file mode 100644 index 0000000..97c23b7 --- /dev/null +++ b/internal/platform/xorg/ocr_click.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#include "commands_native.h" +*/ +import "C" + +import ( + "context" + "errors" + "image" +) + +// ClickOCR performs one X11 button press and release after review ownership +// has been released. The native call checks active client and root dimensions. +func (listener *CommandListener) ClickOCR(ctx context.Context, foreground uint64, root image.Rectangle, point image.Point) error { + if ctx.Err() != nil { + return ctx.Err() + } + listener.secureMutex.Lock() + defer listener.secureMutex.Unlock() + if listener.secure.Load() || listener.ocrApplied.Load() || foreground == 0 || root.Min != (image.Point{}) || !point.In(root) { + return errors.New("OCR click unavailable") + } + if ctx.Err() != nil { + return ctx.Err() + } + if C.cthulhu_ocr_click(C.ulong(foreground), C.int(point.X), C.int(point.Y), C.int(root.Dx()), C.int(root.Dy())) == 0 { + return errors.New("OCR click source changed") + } + return nil +} diff --git a/internal/platform/xorg/ocr_click_native_test.go b/internal/platform/xorg/ocr_click_native_test.go new file mode 100644 index 0000000..f16392a --- /dev/null +++ b/internal/platform/xorg/ocr_click_native_test.go @@ -0,0 +1,83 @@ +package xorg + +import ( + "bufio" + "context" + "encoding/json" + "image" + "os" + "os/exec" + "testing" + "time" +) + +func TestNativeOCRClickReleasesButtonAndRestoresPointer(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR_CLICK") != "1" { + t.Skip("requires private Xorg display") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../../tests/ocr_click_fixture.py") + input, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { input.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(output) + if !scanner.Scan() { + t.Fatal("fixture did not start") + } + var source struct { + Window uint64 + Width, Height int + Pointer []int + } + if err := json.Unmarshal(scanner.Bytes(), &source); err != nil { + t.Fatal(err) + } + listener := &CommandListener{} + root := image.Rect(0, 0, source.Width, source.Height) + listener.secure.Store(true) + if err := listener.ClickOCR(ctx, source.Window, root, image.Pt(60, 60)); err == nil { + t.Fatal("clicked during secure input") + } + listener.secure.Store(false) + if err := listener.ClickOCR(ctx, source.Window+1, root, image.Pt(60, 60)); err == nil { + t.Fatal("clicked wrong foreground") + } + if err := listener.ClickOCR(ctx, source.Window, root, image.Pt(60, 60)); err != nil { + t.Fatal(err) + } + if _, err := input.Write([]byte("events\n")); err != nil { + t.Fatal(err) + } + if !scanner.Scan() { + t.Fatal("fixture did not report events") + } + var result struct { + Events []struct { + Pressed bool + Button, X, Y int + } + Pointer []int + } + if err := json.Unmarshal(scanner.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Events) != 2 || !result.Events[0].Pressed || result.Events[1].Pressed || + result.Events[0].Button != 1 || result.Events[1].Button != 1 || + result.Events[0].X != 60 || result.Events[0].Y != 60 || result.Events[1].X != 60 || result.Events[1].Y != 60 { + t.Fatalf("click events: %+v", result.Events) + } + if len(result.Pointer) != 2 || len(source.Pointer) != 2 || result.Pointer[0] != source.Pointer[0] || result.Pointer[1] != source.Pointer[1] { + t.Fatalf("pointer not restored: %+v", result.Pointer) + } +} diff --git a/internal/platform/xorg/ocr_native_test.go b/internal/platform/xorg/ocr_native_test.go new file mode 100644 index 0000000..d27480d --- /dev/null +++ b/internal/platform/xorg/ocr_native_test.go @@ -0,0 +1,348 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeOCRReviewInput(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_OCR_REVIEW") != "1" { + t.Skip("requires private Xorg display") + } + for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} { + name := "desktop" + if layout == input.LaptopKeyboardLayout { + name = "laptop" + } + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../../tests/ocr_review_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("fixture did not start") + } + var windows map[string]uint64 + if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil { + t.Fatal(err) + } + type deliveredKey struct { + Pressed bool + Symbol uint32 + Modifiers uint16 + Window uint64 + } + type state struct { + Focus uint64 + Keys []deliveredKey + } + action := func(value map[string]any) state { + t.Helper() + data, _ := json.Marshal(value) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() { + t.Fatal("fixture did not respond") + } + var result state + if err := json.Unmarshal(scanner.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + clear := func() { action(map[string]any{"action": "clear"}) } + snapshot := func() state { return action(map[string]any{"action": "state"}) } + listener := NewCommands([]string{"Insert"}, layout) + if err = listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + defer func() { + exec.Command("xdotool", "keyup", "Insert", "Control_L", "Shift_L", "Down", "Escape", "Tab", "o").Run() + }() + ready := func(predicate func() bool) { + t.Helper() + for !predicate() { + select { + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("%v: OCR=%v commandsApplied=%v requested=%v secure=%v", ctx.Err(), listener.ocrApplied.Load(), listener.commandsApplied.Load(), listener.commandsEnabled.Load(), listener.secure.Load()) + case <-time.After(time.Millisecond): + } + } + } + command := func(want input.Command) input.CommandEvent { + t.Helper() + select { + case got := <-listener.Commands(): + if got.Command != want { + t.Fatalf("got %s want %s", got.Command, want) + } + return got + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("%v: OCR=%v commandsApplied=%v requested=%v secure=%v", ctx.Err(), listener.ocrApplied.Load(), listener.commandsApplied.Load(), listener.commandsEnabled.Load(), listener.secure.Load()) + } + return input.CommandEvent{} + } + drained := func() { ready(func() bool { return !listener.CommandGrabActive() }) } + noLeak := func() { + t.Helper() + drained() + got := snapshot() + balance := make(map[uint32]int) + for _, event := range got.Keys { + if event.Symbol != 0xffe1 && event.Symbol != 0xffe3 { + t.Fatalf("review leaked navigation: %+v", got) + } + if event.Pressed { + balance[event.Symbol]++ + } else { + balance[event.Symbol]-- + } + } + for _, count := range balance { + if count != 0 { + t.Fatalf("unbalanced native modifier: %+v", got) + } + } + if got.Focus != windows["window"] { + t.Fatalf("review leaked/changed focus: %+v", got) + } + } + listener.SetBrowseNavigation(true) + ready(listener.BrowseNavigationEnabled) + listener.SetNativeButtonFocus(true) + // A conflict must reject entry and preserve the existing browser context. + action(map[string]any{"action": "conflict"}) + if err = listener.SetOCRReview(ctx, true); err == nil { + t.Fatal("conflicting Ctrl+C grab accepted") + } + if listener.OCRReviewEnabled() || !listener.BrowseNavigationEnabled() { + t.Fatal("failed entry lost old context") + } + key("key", "h") + command(input.DocumentNextHeading) + drained() + action(map[string]any{"action": "unconflict"}) + clear() + // Recognition claims Escape only, even when the application's Ctrl+C + // grab prevents full review. Arrows and structural letters pass. + action(map[string]any{"action": "conflict"}) + if err = listener.SetOCRContext(ctx, windows["other"], true); err == nil { + t.Fatal("pending entry accepted another foreground") + } + if err = listener.SetOCRContext(ctx, windows["window"], true); err != nil { + t.Fatal(err) + } + if listener.OCRReviewEnabled() || listener.BrowseNavigationEnabled() { + t.Fatal("pending context exposed full review/browser navigation") + } + key("key", "Down") + key("key", "h") + gotPending := snapshot() + if len(gotPending.Keys) != 4 || gotPending.Keys[0].Symbol != 0xff54 || gotPending.Keys[2].Symbol != 'h' { + t.Fatalf("pending navigation: %+v", gotPending) + } + clear() + key("key", "Escape") + command(input.OCRDismiss) + noLeak() + ready(listener.BrowseNavigationEnabled) + action(map[string]any{"action": "unconflict"}) + clear() + // A timed-out entry must never activate when the held key later releases. + key("keydown", "Insert") + ready(listener.CommandGrabActive) + expired, cancelEntry := context.WithTimeout(ctx, 25*time.Millisecond) + if err = listener.SetOCRReview(expired, true); err != context.DeadlineExceeded { + t.Fatalf("waiting entry: %v", err) + } + cancelEntry() + key("keyup", "Insert") + if err = listener.SetOCRReview(ctx, false); err != nil { + t.Fatal(err) + } + if listener.OCRReviewEnabled() { + t.Fatal("expired entry activated later") + } + // Entry waits for the full initiating chord, including modifier release. + key("keydown", "Insert") + ready(listener.CommandGrabActive) + applied := make(chan error, 1) + go func() { applied <- listener.SetOCRReview(ctx, true) }() + select { + case err := <-applied: + t.Fatalf("entered on held modifier: %v", err) + case <-time.After(25 * time.Millisecond): + } + key("keyup", "Insert") + if err = <-applied; err != nil { + t.Fatal(err) + } + ready(listener.OCRReviewEnabled) + clear() + for _, test := range []struct { + chord string + want input.Command + }{ + {"Down", input.OCRNextLine}, {"Up", input.OCRPreviousLine}, {"Left", input.OCRPreviousCharacter}, + {"ctrl+Right", input.OCRNextWord}, {"Home", input.OCRLineStart}, {"ctrl+End", input.OCRDocumentEnd}, + {"shift+Down", input.OCRNextLine}, {"ctrl+a", input.OCRSelectAll}, {"ctrl+c", input.OCRCopySelection}, + {"Return", input.OCRActivate}, {"space", input.OCRActivate}, + } { + key("key", test.chord) + event := command(test.want) + if test.chord == "shift+Down" && event.Chord.Modifiers&1 == 0 { + t.Fatal("selection modifier lost") + } + noLeak() + clear() + } + if layout == input.LaptopKeyboardLayout { + key("key", "Insert+o") + command(input.ReviewNextLine) + noLeak() + clear() + } + // Alt+Tab remains available to the application/window manager. + key("key", "alt+Tab") + got := snapshot() + tabPresses, tabReleases := 0, 0 + for _, event := range got.Keys { + if event.Symbol == 0xff09 { + if event.Pressed { + tabPresses++ + } else { + tabReleases++ + } + } + } + if tabPresses != 1 || tabReleases != 1 { + t.Fatalf("Alt+Tab did not pass: %+v", got) + } + clear() + // Structural letters reach the app rather than inventing OCR headings. + key("key", "h") + got = snapshot() + if len(got.Keys) != 2 || got.Keys[0].Symbol != 'h' { + t.Fatalf("structural letter: %+v", got) + } + clear() + key("key", "ctrl+z") + got = snapshot() + if len(got.Keys) != 4 { + t.Fatalf("unbound Control chord not replayed whole: %+v", got) + } + clear() + // Exit also waits for the complete navigation key release. + key("keydown", "Down") + command(input.OCRNextLine) + go func() { applied <- listener.SetOCRReview(ctx, false) }() + select { + case err := <-applied: + t.Fatalf("exited on held arrow: %v", err) + case <-time.After(25 * time.Millisecond): + } + key("keyup", "Down") + if err = <-applied; err != nil { + t.Fatal(err) + } + noLeak() + ready(listener.BrowseNavigationEnabled) + key("key", "h") + command(input.DocumentNextHeading) + drained() + clear() + if err = listener.SetOCRReview(ctx, true); err != nil { + t.Fatal(err) + } + key("key", "Escape") + command(input.OCRDismiss) + ready(func() bool { return !listener.OCRReviewEnabled() }) + noLeak() + for _, chord := range []string{"Tab", "shift+Tab"} { + if err = listener.SetOCRReview(ctx, true); err != nil { + t.Fatal(err) + } + clear() + key("key", chord) + command(input.OCRDismissAndPass) + ready(func() bool { return !listener.OCRReviewEnabled() }) + drained() + got = snapshot() + presses, releases := 0, 0 + for _, event := range got.Keys { + if event.Symbol == 0xff09 { + if event.Pressed { + presses++ + } else { + releases++ + } + } + } + if got.Focus != windows["window"] || presses != 1 || releases != 1 { + t.Fatalf("Tab replay: %+v", got) + } + } + // Focus loss retires temporary input; a following arrow reaches the new app. + if err = listener.SetOCRReview(ctx, true); err != nil { + t.Fatal(err) + } + listener.SetBrowseNavigation(false) + action(map[string]any{"action": "activate", "other": true}) + command(input.OCRDismiss) + ready(func() bool { return !listener.OCRReviewEnabled() }) + clear() + key("key", "Down") + got = snapshot() + if len(got.Keys) != 2 || got.Keys[0].Window != windows["other"] { + t.Fatalf("focus-loss routing: %+v", got) + } + action(map[string]any{"action": "activate"}) + if err = listener.SetOCRReview(ctx, true); err != nil { + t.Fatal(err) + } + listener.SetSecureInputActive(true) + ready(func() bool { return !listener.ocrApplied.Load() && !listener.CommandGrabsEnabled() }) + listener.SetSecureInputActive(false) + ready(listener.CommandGrabsEnabled) + if listener.OCRReviewEnabled() { + t.Fatal("unlock revived OCR") + } + }) + } +} diff --git a/internal/platform/xorg/pointer.go b/internal/platform/xorg/pointer.go new file mode 100644 index 0000000..c2e151b --- /dev/null +++ b/internal/platform/xorg/pointer.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package xorg + +/* +#cgo pkg-config: x11 +#include "commands_native.h" +*/ +import "C" + +type PointerPosition struct { + X, Y, PID int + Window uint64 + Known bool +} + +// PointerReader owns one X connection. Use it from one goroutine and close it +// after that goroutine has stopped. +type PointerReader struct{ display *C.Display } + +func OpenPointerReader() *PointerReader { + display := C.XOpenDisplay(nil) + if display == nil { + return nil + } + return &PointerReader{display: display} +} + +func (reader *PointerReader) Read() PointerPosition { + if reader == nil || reader.display == nil { + return PointerPosition{} + } + value := C.cthulhu_read_pointer(reader.display) + return PointerPosition{X: int(value.x), Y: int(value.y), PID: int(value.pid), + Window: uint64(value.window), Known: value.known != 0} +} + +func (reader *PointerReader) Close() { + if reader != nil && reader.display != nil { + C.XCloseDisplay(reader.display) + reader.display = nil + } +} diff --git a/internal/platform/xorg/scroll_lock_native_test.go b/internal/platform/xorg/scroll_lock_native_test.go new file mode 100644 index 0000000..567d14f --- /dev/null +++ b/internal/platform/xorg/scroll_lock_native_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeScrollLockCommandGrabs(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + for _, shared := range []bool{false, true} { + name := "scroll_only" + if shared { + name = "shared_level_shift" + } + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + run := func(program string, args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, program, args...).CombinedOutput(); err != nil { + t.Fatalf("%s: %v %s", program, err, out) + } + } + original, err := exec.CommandContext(ctx, "xmodmap", "-pm").Output() + if err != nil { + t.Fatal(err) + } + var originalSymbols []string + for _, line := range strings.Split(string(original), "\n") { + fields := strings.Fields(line) + if len(fields) > 0 && fields[0] == "mod3" { + for index := 1; index < len(fields); index += 2 { + originalSymbols = append(originalSymbols, fields[index]) + } + } + } + defer func() { + args := []string{"-e", "clear mod3"} + if len(originalSymbols) > 0 { + args = append(args, "-e", "add mod3 = "+strings.Join(originalSymbols, " ")) + } + if out, err := exec.Command("xmodmap", args...).CombinedOutput(); err != nil { + t.Errorf("restore modifier mapping: %v %s", err, out) + } + }() + mapping := "Scroll_Lock" + if shared { + mapping += " ISO_Level5_Shift" + } + run("xmodmap", "-e", "clear mod3", "-e", "add mod3 = "+mapping) + + fixture := exec.CommandContext(ctx, "python3", "-u", "../../../tests/ocr_review_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("key sink did not start") + } + type deliveredKey struct { + Pressed bool + Symbol uint32 + Modifiers uint16 + } + action := func(command map[string]string) []deliveredKey { + t.Helper() + data, _ := json.Marshal(command) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() { + t.Fatal("key sink did not respond") + } + var state struct{ Keys []deliveredKey } + if err := json.Unmarshal(scanner.Bytes(), &state); err != nil { + t.Fatal(err) + } + return state.Keys + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.SetKeyBindings(ctx, []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"Cthulhu+Space", "Cthulhu+Ctrl+Alt+y"}}}); err != nil { + t.Fatal(err) + } + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + command := func(want input.Command) { + t.Helper() + select { + case event := <-listener.Commands(): + if event.Command != want { + t.Fatalf("command = %s, want %s", event.Command, want) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("missing command %s", want) + } + for listener.CommandGrabActive() { + select { + case <-ctx.Done(): + t.Fatal("command grab did not release") + case <-time.After(time.Millisecond): + } + } + } + press := func(name string) { + t.Helper() + action(map[string]string{"action": "key", "name": name}) + } + press("Scroll_Lock") + defer exec.Command("xdotool", "key", "Scroll_Lock").Run() + action(map[string]string{"action": "clear"}) + press("KP_Begin") + if shared { + keys := action(map[string]string{"action": "state"}) + if len(keys) != 2 || keys[0].Symbol != 0xff9d || !keys[0].Pressed || keys[1].Pressed || keys[0].Modifiers&32 == 0 { + t.Fatalf("shared layout modifier was consumed: %+v", keys) + } + return + } + command(input.ReviewCurrentWord) + for _, test := range []struct { + chord string + want input.Command + }{ + {"Insert+space", input.Preferences}, + {"Insert+Control_L+Down", input.ObjectFirstChild}, + {"Insert+Shift_L+v", input.PresentVersion}, + {"Insert+Control_L+Alt_L+y", input.Preferences}, + } { + run("xdotool", "key", test.chord) + command(test.want) + } + if keys := action(map[string]string{"action": "state"}); len(keys) != 0 { + t.Fatalf("reader commands leaked: %+v", keys) + } + press("Num_Lock") + defer exec.Command("xdotool", "key", "Num_Lock").Run() + action(map[string]string{"action": "clear"}) + press("KP_Begin") + keys := action(map[string]string{"action": "state"}) + if len(keys) != 2 || keys[0].Symbol != 0xff9d || !keys[0].Pressed || keys[1].Pressed || keys[0].Modifiers&16 == 0 { + t.Fatalf("NumLock typing was consumed: %+v", keys) + } + select { + case got := <-listener.Commands(): + t.Fatalf("unbound key dispatched: %+v", got) + default: + } + }) + } +} diff --git a/internal/platform/xorg/tentacle_native_test.go b/internal/platform/xorg/tentacle_native_test.go new file mode 100644 index 0000000..6300f32 --- /dev/null +++ b/internal/platform/xorg/tentacle_native_test.go @@ -0,0 +1,197 @@ +package xorg + +import ( + "context" + "git.stormux.org/storm/cthulhu/internal/input" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeTentacleBindingsUpdateAndRelease(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Own a foreground client; browse-only grabs deliberately replay when + // no client owns focus or the foreground window has changed. + probe := exec.CommandContext(ctx, "xev", "-name", "Cthulhu Tentacle Bindings", "-event", "keyboard") + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + window := "" + for window == "" { + output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Tentacle Bindings$").Output() + if err == nil { + window = strings.TrimSpace(string(output)) + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + custom := []input.BindingOverride{ + {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Command("tentacle/test/global"), Keys: []string{"Cthulhu+Ctrl+Alt+y"}}, + {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Command("tentacle/test/global"), Keys: []string{"F9"}}, + {Layout: input.DesktopKeyboardLayout, Scope: "browse", Command: input.Command("tentacle/test/browse"), Keys: []string{"F6"}}, + {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Command("tentacle/test/wormhole"), Keys: []string{"Cthulhu+Ctrl+Shift+W"}}, + } + if err := listener.SetTentacleBindings(ctx, custom); err != nil { + t.Fatal(err) + } + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + command := func(want input.Command) { + t.Helper() + select { + case got := <-listener.Commands(): + if got.Command != want { + t.Fatalf("command %s want %s", got.Command, want) + } + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + key("keydown", "Insert", "keydown", "Control_L", "keydown", "Alt_L", "keydown", "y") + command(input.Command("tentacle/test/global")) + // Queue a new configuration while Y is held: it must wait for releases. + applied := make(chan error, 1) + go func() { applied <- listener.SetTentacleBindings(ctx, nil) }() + select { + case err := <-applied: + t.Fatalf("update completed while keys were held: %v", err) + case <-time.After(40 * time.Millisecond): + } + key("keyup", "Insert", "keyup", "Control_L", "keyup", "Alt_L", "keyup", "y") + if err := <-applied; err != nil { + t.Fatal(err) + } + key("keydown", "Insert", "key", "space", "keyup", "Insert") + command(input.Preferences) + if err := listener.SetTentacleBindings(ctx, custom); err != nil { + t.Fatal(err) + } + key("key", "F9") + command(input.Command("tentacle/test/global")) + key("keydown", "Insert", "keydown", "Control_L", "keydown", "Shift_L", "key", "w", "keyup", "Shift_L", "keyup", "Control_L", "keyup", "Insert") + command(input.Command("tentacle/test/wormhole")) + // All modifiers released: an ordinary W must reach the focused application. + key("key", "w") + key("key", "F9") + command(input.Command("tentacle/test/global")) + if err := listener.SetKeyBindings(ctx, []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"F9"}}}); err != nil { + t.Fatal(err) + } + key("key", "F9") + command(input.Preferences) + if err := listener.SetKeyBindings(ctx, nil); err != nil { + t.Fatal(err) + } + key("key", "F9") + command(input.Command("tentacle/test/global")) + key("keydown", "Insert", "key", "h", "keyup", "Insert") + command(input.ToggleLearnMode) + key("key", "F9") + command(input.LearnKey) + key("key", "Escape") + command(input.ToggleLearnMode) + listener.SetBrowseNavigation(true) + for !listener.BrowseNavigationEnabled() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + key("key", "F6") + command(input.Command("tentacle/test/browse")) + listener.SetBrowseNavigation(false) + for listener.browseApplied.Load() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + key("key", "F6") + key("key", "F9") + command(input.Command("tentacle/test/global")) // F6 outside browse mode must not dispatch. + competitor := NewCommands([]string{"KP_Insert"}, input.LaptopKeyboardLayout) + if err := competitor.SetKeyBindings(ctx, []input.BindingOverride{{Layout: input.LaptopKeyboardLayout, Scope: "global", Command: input.PresentDate, Keys: []string{"F10"}}}); err != nil { + t.Fatal(err) + } + if err := competitor.Start(ctx); err != nil { + t.Fatal(err) + } + collision := input.CloneBindingOverrides(custom) + collision[0].Keys = []string{"F10"} + if err := listener.SetTentacleBindings(ctx, collision); err == nil { + t.Fatal("accepted conflicting native grab") + } + key("key", "F9") + command(input.Command("tentacle/test/global")) // Native rollback retained the old shortcut. + browseCollision := input.CloneBindingOverrides(custom) + browseCollision[2].Keys = []string{"F10"} + if err := listener.SetTentacleBindings(ctx, browseCollision); err == nil { + t.Fatal("accepted conflicting prospective browse grab") + } + key("key", "F9") + command(input.Command("tentacle/test/global")) + if err := competitor.Stop(ctx); err != nil { + t.Fatal(err) + } + unavailable := input.CloneBindingOverrides(custom) + unavailable[0].Keys = []string{"Cthulhu+F35"} + if err := listener.SetTentacleBindings(ctx, unavailable); err == nil { + t.Fatal("accepted unavailable key") + } + key("key", "F9") + command(input.Command("tentacle/test/global")) // Rejected update preserved the previous router. + listener.SetSecureInputActive(true) + for listener.CommandGrabsEnabled() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + key("key", "F9") + listener.SetSecureInputActive(false) + for !listener.CommandGrabsEnabled() { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + key("key", "F9") + command(input.Command("tentacle/test/global")) + if err := listener.SetTentacleBindings(ctx, nil); err != nil { + t.Fatal(err) + } + if len(listener.TentacleBindings()) != 0 { + t.Fatal("removed bindings still published") + } + key("key", "F9") + key("keydown", "Insert", "key", "space", "keyup", "Insert") + command(input.Preferences) +} diff --git a/internal/platform/xorg/ui_native_test.go b/internal/platform/xorg/ui_native_test.go new file mode 100644 index 0000000..4f3aab9 --- /dev/null +++ b/internal/platform/xorg/ui_native_test.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeInternalUICapturesAndReleases(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + log, err := os.CreateTemp(t.TempDir(), "keys-") + if err != nil { + t.Fatal(err) + } + defer log.Close() + probe := exec.CommandContext(ctx, "stdbuf", "-oL", "xev", "-name", "Internal UI probe", "-event", "keyboard") + probe.Stdout = log + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + var window string + for window == "" { + data, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Internal UI probe$").Output() + if err == nil { + window = strings.TrimSpace(string(data)) + } + if ctx.Err() != nil { + t.Fatal("probe did not open") + } + time.Sleep(10 * time.Millisecond) + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(names ...string) { + t.Helper() + args := append([]string{"key"}, names...) + if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("keys %v: %v %s", names, err, data) + } + } + want := func(command input.Command) input.CommandEvent { + t.Helper() + select { + case event := <-listener.Commands(): + if event.Command != command { + t.Fatalf("command=%s, want %s", event.Command, command) + } + return event + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatalf("missing %s", command) + } + return input.CommandEvent{} + } + key("Insert+shift+a") + opened := want(input.ShowActions) + if opened.UISession == 0 || listener.InternalUISession() != opened.UISession { + t.Fatal("actions did not capture atomically") + } + for _, name := range []string{"Down", "Up", "shift+a", "exclam", "U00e9", "Return"} { + key(name) + event := want(input.InternalUIKey) + if event.UISession != opened.UISession { + t.Fatal("wrong session") + } + if name == "shift+a" && event.Text != "A" { + t.Fatalf("shift translation=%q", event.Text) + } + if name == "exclam" && event.Text != "!" { + t.Fatalf("punctuation=%q", event.Text) + } + if name == "U00e9" && event.Text != "é" { + t.Fatalf("unicode=%q", event.Text) + } + } + if focus, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output(); err != nil || strings.TrimSpace(string(focus)) != window { + t.Fatal("capture changed application focus") + } + key("Escape") + want(input.InternalUIKey) + for listener.InternalUIEchoActive() { + if ctx.Err() != nil { + t.Fatal("Escape did not release") + } + time.Sleep(time.Millisecond) + } + key("y") + // An independently opened form uses the same ownership and stale-close rules. + second, err := listener.BeginInternalUI(ctx) + if err != nil { + t.Fatal(err) + } + if err := listener.EndInternalUI(ctx, opened.UISession); err != nil { + t.Fatal(err) + } + if listener.InternalUISession() != second { + t.Fatal("stale close revoked new form") + } + key("z") + want(input.InternalUIKey) + if err := exec.CommandContext(ctx, "xdotool", "keydown", "Return").Run(); err != nil { + t.Fatal(err) + } + want(input.InternalUIKey) + closed := make(chan error, 1) + go func() { closed <- listener.EndInternalUI(ctx, second) }() + for listener.InternalUISession() != 0 { + if ctx.Err() != nil { + t.Fatal("held-key close did not revoke UI") + } + time.Sleep(time.Millisecond) + } + select { + case <-closed: + t.Fatal("close acknowledged before physical release") + default: + } + if err := exec.CommandContext(ctx, "xdotool", "keyup", "Return").Run(); err != nil { + t.Fatal(err) + } + if err := <-closed; err != nil { + t.Fatal(err) + } + if err := listener.EndInternalUI(ctx, second); err != nil { + t.Fatal(err) + } + third, err := listener.BeginInternalUI(ctx) + if err != nil { + t.Fatal(err) + } + if err := exec.CommandContext(ctx, "xdotool", "keydown", "Shift_L", "F12").Run(); err != nil { + t.Fatal(err) + } + want(input.InternalUIKey) + listener.SetSecureInputActive(true) + for listener.InternalUISession() != 0 || listener.InternalUIEchoActive() { + if ctx.Err() != nil { + t.Fatal("lock did not revoke") + } + time.Sleep(time.Millisecond) + } + if err := exec.CommandContext(ctx, "xdotool", "keyup", "F12", "Shift_L").Run(); err != nil { + t.Fatal(err) + } + if _, err := listener.BeginInternalUI(ctx); err == nil { + t.Fatal("opened during lock") + } + listener.SetSecureInputActive(false) + if err := listener.EndInternalUI(ctx, third); err != nil { + t.Fatal(err) + } + if background := os.Getenv("CTHULHU_UI_BACKGROUND"); background != "" { + fourth, err := listener.BeginInternalUI(ctx) + if err != nil { + t.Fatal(err) + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", background).Run(); err != nil { + t.Fatal(err) + } + dismissed := want(input.InternalUIDismiss) + if dismissed.UISession != fourth || listener.InternalUISession() != 0 { + t.Fatal("focus change did not revoke interaction") + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + } + key("Insert+t") + want(input.PresentTime) + key("y") + _ = probe.Process.Kill() + _ = probe.Wait() + data, err := os.ReadFile(log.Name()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"keysym 0xff54, Down", "keysym 0xff52, Up", "keysym 0xff0d, Return", "keysym 0xff1b, Escape", "keysym 0x7a, z", "keysym 0x41, A", "keysym 0x21, exclam", "keysym 0xe9, eacute"} { + if strings.Contains(string(data), name) { + t.Fatalf("UI key leaked to application: %s", name) + } + } + if !strings.Contains(string(data), "keysym 0x79, y") { + t.Fatal("ordinary typing did not resume") + } +} diff --git a/internal/platform/xorg/window_title.c b/internal/platform/xorg/window_title.c new file mode 100644 index 0000000..6f66a98 --- /dev/null +++ b/internal/platform/xorg/window_title.c @@ -0,0 +1,272 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "window_title.h" +#include "commands_native.h" +#include +#include +#include +#include +#include +#include +#include + +static unsigned long read_pid(Display *display, Window window); +static int wine_process(Display *display, Window window) { + unsigned long pid = read_pid(display, window); + if (!pid) return 0; + char path[64], executable[1024]; + snprintf(path, sizeof(path), "/proc/%lu/exe", pid); + ssize_t length = readlink(path, executable, sizeof(executable) - 1); + if (length <= 0 || length >= (ssize_t)sizeof(executable) - 1) return 0; + executable[length] = 0; + const char *name = strrchr(executable, '/'); + if (!name) return 0; + ++name; + return !strcmp(name, "wine") || !strcmp(name, "wine64") || + !strcmp(name, "wine-preloader") || !strcmp(name, "wine64-preloader"); +} + +static char *read_title(Display *display, Window window) { + Atom actualType; + int format; + unsigned long items, remaining; + unsigned char *data = NULL; + Atom name = XInternAtom(display, "_NET_WM_NAME", False); + if (XGetWindowProperty(display, window, name, 0, 1024, False, + XInternAtom(display, "UTF8_STRING", False), &actualType, &format, + &items, &remaining, &data) == Success && data && format == 8 && items) { + char *title = strndup((char *)data, items); + XFree(data); + return title; + } + if (data) XFree(data); + XTextProperty property = {0}; + char *title = NULL; + if (XGetWMName(display, window, &property) && property.value) { + char **strings = NULL; + int count = 0; + if (Xutf8TextPropertyToTextList(display, &property, &strings, &count) >= Success && count > 0) + title = strdup(strings[0]); + if (strings) XFreeStringList(strings); + XFree(property.value); + } + return title; +} + +static int is_wine_desktop(const char *title) { + return title && strcasestr(title, "Wine Desktop") != NULL; +} + +static char *read_application(Display *display, Window window) { + XClassHint hint = {0}; + char *application = NULL; + if (XGetClassHint(display, window, &hint)) { + const char *name = hint.res_name && *hint.res_name ? hint.res_name : hint.res_class; + if (name) application = strdup(name); + } + if (hint.res_name) XFree(hint.res_name); + if (hint.res_class) XFree(hint.res_class); + return application; +} + +static Window parent_window(Display *display, Window window) { + Window root, parent, *children = NULL; + unsigned int count; + if (!XQueryTree(display, window, &root, &parent, &children, &count)) return None; + if (children) XFree(children); + return parent; +} + +static int is_descendant(Display *display, Window window, Window desktop) { + for (int depth = 0; window && depth < 64; depth++) { + if (window == desktop) return 1; + Window parent = parent_window(display, window); + if (parent == window) break; + window = parent; + } + return 0; +} + +static int usable_child(Display *display, Window window, WindowTitle *result) { + XWindowAttributes attributes; + if (!XGetWindowAttributes(display, window, &attributes) || attributes.map_state != IsViewable) + return 0; + char *title = read_title(display, window); + if (!title || !*title || is_wine_desktop(title) || + !strcasecmp(title, "Default IME") || !strcasecmp(title, "Program Manager")) { + free(title); + return 0; + } + char *application = read_application(display, window); + XClassHint hint = {0}; + XGetClassHint(display, window, &hint); + const char *identifiers[] = {hint.res_name, hint.res_class}; + int infrastructure = 0; + for (unsigned int i = 0; i < 2; i++) { + if (identifiers[i] && (!strcasecmp(identifiers[i], "cthulhu-wine-access.exe") || + (!strcasecmp(identifiers[i], "nvda.exe") && !strcasecmp(title, "NVDA")))) + infrastructure = 1; + } + if (hint.res_name) XFree(hint.res_name); + if (hint.res_class) XFree(hint.res_class); + if (infrastructure) { + free(title); + free(application); + return 0; + } + result->titleWindow = window; + result->title = title; + result->application = application; + return 1; +} + +// Prefer the focused child's titled ancestor, after proving that focus belongs +// to this desktop. Fallback traversal is bounded and ignores hidden children. +static void read_wine_title(Display *display, Window desktop, WindowTitle *result) { + Window focus; + int revert; + XGetInputFocus(display, &focus, &revert); + if (focus != None && focus != PointerRoot && is_descendant(display, focus, desktop)) { + for (int depth = 0; focus && focus != desktop && depth < 64; depth++) { + if (usable_child(display, focus, result)) { result->focused = 1; return; } + focus = parent_window(display, focus); + } + } + Window queue[200] = {desktop}; + unsigned int queued = 1; + for (unsigned int index = 0; index < queued; index++) { + if (index && usable_child(display, queue[index], result)) return; + Window root, parent, *children = NULL; + unsigned int count; + if (XQueryTree(display, queue[index], &root, &parent, &children, &count)) { + for (unsigned int child = 0; child < count && queued < 200; child++) + queue[queued++] = children[child]; + } + if (children) XFree(children); + } +} + +WindowTitle cthulhu_read_window_title(Display *display, Window window) { + WindowTitle result = {0}; + if (!display || !window) return result; + cthulhu_x11_begin_read(display); + XWindowAttributes attributes; + if (XGetWindowAttributes(display, window, &attributes) && attributes.map_state == IsViewable) { + result.window = window; + char *title = read_title(display, window); + if (is_wine_desktop(title)) { + result.wine = 1; + free(title); + read_wine_title(display, window, &result); + } else { + result.titleWindow = window; + result.title = title; + result.application = read_application(display, window); + XClassHint hint = {0}; + if (XGetClassHint(display, window, &hint)) { + result.wine = hint.res_class && !strcasecmp(hint.res_class, "Wine"); + if (hint.res_name) XFree(hint.res_name); + if (hint.res_class) XFree(hint.res_class); + } + // Current Wine uses the program's .exe for both WM_CLASS fields. + // The actual client process distinguishes it from a Linux window + // with the same title or class, without any prefix environment. + result.wine = result.wine || wine_process(display, window); + Window focus; + int revert; + XGetInputFocus(display, &focus, &revert); + result.focused = focus != None && focus != PointerRoot && is_descendant(display, focus, window); + } + XWindowAttributes child; + Window translated; + if (result.titleWindow && XGetWindowAttributes(display, result.titleWindow, &child) && + child.map_state == IsViewable && XTranslateCoordinates(display, result.titleWindow, + DefaultRootWindow(display), 0, 0, &result.x, &result.y, &translated)) { + result.width = child.width; + result.height = child.height; + } + } + if (cthulhu_x11_end_read(display)) { + free(result.title); + free(result.application); + result = (WindowTitle){0}; + } + return result; +} + +static unsigned long read_pid(Display *display, Window window) { + Atom type; + int format; + unsigned long items, remaining, pid = 0; + unsigned char *data = NULL; + if (XGetWindowProperty(display, window, XInternAtom(display, "_NET_WM_PID", False), + 0, 1, False, XA_CARDINAL, &type, &format, &items, &remaining, &data) == Success && + data && type == XA_CARDINAL && format == 32 && items) pid = *(unsigned long *)data; + if (data) XFree(data); + return pid; +} + +// Enumerate clients, then Wine desktop children, without relying on focus. +// A bounded root traversal also serves window managers without an EWMH list. +ApplicationWindow *cthulhu_list_application_windows(Display *display, int *count) { + *count = 0; + if (!display) return NULL; + ApplicationWindow *result = calloc(4096, sizeof(*result)); + if (!result) return NULL; + cthulhu_x11_begin_read(display); + Window queue[4096]; + int wine[4096] = {0}; + unsigned int queued = 0; + Atom type; + int format; + unsigned long items, remaining; + unsigned char *data = NULL; + Window root = DefaultRootWindow(display); + if (XGetWindowProperty(display, root, XInternAtom(display, "_NET_CLIENT_LIST", False), + 0, 4096, False, XA_WINDOW, &type, &format, &items, &remaining, &data) == Success && + data && type == XA_WINDOW && format == 32 && items) { + Window *clients = (Window *)data; + for (unsigned long i = 0; i < items && queued < 4096; i++) queue[queued++] = clients[i]; + } else { + queue[queued++] = root; + } + if (data) XFree(data); + for (unsigned int i = 0; i < queued; i++) { + char *title = read_title(display, queue[i]); + int desktop = is_wine_desktop(title); + free(title); + char *application = read_application(display, queue[i]); + int descend = !application || !*application || desktop || wine[i]; + if (application && *application && !desktop) { + // Skip Wine infrastructure using the same title discovery rules. + WindowTitle child = {0}; + if (!wine[i] || usable_child(display, queue[i], &child)) { + result[*count] = (ApplicationWindow){application, read_pid(display, queue[i]), wine[i]}; + (*count)++; + application = NULL; + } + free(child.title); + free(child.application); + } + free(application); + if (descend) { + Window treeRoot, parent, *children = NULL; + unsigned int childCount; + if (XQueryTree(display, queue[i], &treeRoot, &parent, &children, &childCount)) { + for (unsigned int j = 0; j < childCount && queued < 4096; j++) { + queue[queued] = children[j]; + wine[queued++] = wine[i] || desktop; + } + } + if (children) XFree(children); + } + } + cthulhu_x11_end_read(display); + return result; +} + +void cthulhu_free_application_windows(ApplicationWindow *windows, int count) { + for (int i = 0; i < count; i++) free(windows[i].application); + free(windows); +} diff --git a/internal/platform/xorg/window_title.go b/internal/platform/xorg/window_title.go new file mode 100644 index 0000000..e3aefc9 --- /dev/null +++ b/internal/platform/xorg/window_title.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#cgo CFLAGS: -D_GNU_SOURCE +#include "window_title.h" +#include +*/ +import "C" + +import ( + "strings" + "unsafe" +) + +// WindowTitle contains only copied metadata. Window is the foreground client; +// TitleWindow may be a program inside its Wine desktop. Application is WM_CLASS +// for programs without an AT-SPI application identity. +type WindowTitle struct { + Window, TitleWindow uint64 + Title, Application string + Wine, Focused bool + Client Foreground +} + +// ReadTitle runs on the same native owner thread as Read and Close. +func (reader *ForegroundReader) ReadTitle(window uint64) WindowTitle { + if reader == nil || reader.display == nil || window == 0 { + return WindowTitle{} + } + value := C.cthulhu_read_window_title(reader.display, C.Window(window)) + defer C.free(unsafe.Pointer(value.title)) + defer C.free(unsafe.Pointer(value.application)) + return WindowTitle{Window: uint64(value.window), TitleWindow: uint64(value.titleWindow), + Wine: value.wine != 0, Focused: value.focused != 0, + Client: Foreground{Known: value.width > 0 && value.height > 0, Window: uint64(value.titleWindow), X: int(value.x), Y: int(value.y), Width: int(value.width), Height: int(value.height)}, + Title: strings.TrimSpace(strings.ToValidUTF8(C.GoString(value.title), "\ufffd")), + Application: strings.TrimSpace(strings.ToValidUTF8(C.GoString(value.application), "\ufffd"))} +} diff --git a/internal/platform/xorg/window_title.h b/internal/platform/xorg/window_title.h new file mode 100644 index 0000000..59879eb --- /dev/null +++ b/internal/platform/xorg/window_title.h @@ -0,0 +1,21 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef CTHULHU_WINDOW_TITLE_H +#define CTHULHU_WINDOW_TITLE_H +#include + +typedef struct { + unsigned long window, titleWindow; + char *title, *application; + int wine, focused, x, y, width, height; +} WindowTitle; +WindowTitle cthulhu_read_window_title(Display *display, Window window); +typedef struct { + char *application; + unsigned long pid; + int wine; +} ApplicationWindow; +ApplicationWindow *cthulhu_list_application_windows(Display *display, int *count); +void cthulhu_free_application_windows(ApplicationWindow *windows, int count); +#endif diff --git a/internal/platform/xorg/window_title_native_test.go b/internal/platform/xorg/window_title_native_test.go new file mode 100644 index 0000000..643fa1d --- /dev/null +++ b/internal/platform/xorg/window_title_native_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "runtime" + "testing" + "time" +) + +func TestNativeWindowTitleDiscovery(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_WINDOW_TITLES") != "1" { + t.Skip("isolated Xvfb fixture only") + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "python3", "-u", "../../../tests/window_title_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err = fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("fixture did not start") + } + var windows map[string]uint64 + if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil { + t.Fatal(err) + } + command := func(action, window, title string) { + t.Helper() + data, _ := json.Marshal(map[string]string{"action": action, "window": window, "title": title}) + if _, err = stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("fixture command failed") + } + } + reader := OpenForegroundReader() + if reader == nil { + t.Fatal("cannot open X11") + } + defer reader.Close() + applications := reader.ApplicationWindows() + var foundExample, foundGame bool + for _, application := range applications { + switch application.Application { + case "example": + foundExample = true + case "game.exe": + foundGame = application.Wine + case "cthulhu-wine-access.exe", "hidden.exe": + t.Fatalf("infrastructure/hidden Wine window listed: %+v", application) + } + } + if !foundExample || !foundGame { + t.Fatalf("running applications incomplete: %+v", applications) + } + expect := func(window, titledWindow, title, application string) { + t.Helper() + foreground := reader.Read() + value := reader.ReadTitle(foreground.Window) + if value.Window != windows[window] || value.TitleWindow != windows[titledWindow] || value.Title != title || value.Application != application { + t.Fatalf("title = %+v; want %s/%s %q application %q", value, window, titledWindow, title, application) + } + } + expect("normal", "normal", "Example title", "example") + command("title", "normal", "Unicode café 日本語") + expect("normal", "normal", "Unicode café 日本語", "example") + command("legacy", "normal", "Legacy title") + expect("normal", "normal", "Legacy title", "example") + command("activate", "desktop", "") + command("focus", "control", "") + expect("desktop", "game", "Wine game", "game.exe") + command("title", "game", "Wine game loading") + expect("desktop", "game", "Wine game loading", "game.exe") + command("focus", "second", "") + expect("desktop", "second", "Wine dialog", "game.exe") + command("focus", "unrelated", "") + expect("desktop", "game", "Wine game loading", "game.exe") + command("hide", "game", "") + expect("desktop", "second", "Wine dialog", "game.exe") + command("destroy", "desktop", "") + if value := reader.ReadTitle(windows["desktop"]); value != (WindowTitle{}) { + t.Fatalf("destroyed desktop = %+v", value) + } + command("activate", "normal", "") + expect("normal", "normal", "Legacy title", "example") +} diff --git a/internal/platform/xorg/xterm_handoff_native_test.go b/internal/platform/xorg/xterm_handoff_native_test.go new file mode 100644 index 0000000..28dfecf --- /dev/null +++ b/internal/platform/xorg/xterm_handoff_native_test.go @@ -0,0 +1,191 @@ +package xorg + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeSuspendedQueuedKey(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + probe := filepath.Join(t.TempDir(), "suspension-probe") + if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lpthread").CombinedOutput(); err != nil { + t.Fatalf("compile native probe: %v: %s", err, out) + } + for _, mode := range []string{"queued", "replay", "passive"} { + t.Run(mode, func(t *testing.T) { + if out, err := exec.CommandContext(ctx, probe, mode).CombinedOutput(); err != nil { + t.Fatalf("native suspension: %v: %s", err, out) + } + }) + } +} + +// Exercise the native grab owner, including the late SetCommandGrabs request +// made by accessibility focus handling after the native xterm handoff. +func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + for _, scenario := range []struct { + name string + learn, held bool + }{ + {name: "delayed_focus"}, + {name: "held_command", held: true}, + {name: "learn_mode", learn: true}, + } { + t.Run(scenario.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + fixture := exec.CommandContext(ctx, "python3", "-u", "../../../tests/ocr_review_fixture.py") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatal("key sink did not start") + } + type deliveredKey struct { + Pressed bool + Symbol uint32 + } + action := func(command map[string]string) []deliveredKey { + t.Helper() + data, _ := json.Marshal(command) + if _, err := stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + if !scanner.Scan() { + t.Fatal("key sink did not respond") + } + var state struct{ Keys []deliveredKey } + if err := json.Unmarshal(scanner.Bytes(), &state); err != nil { + t.Fatal(err) + } + return state.Keys + } + listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + wait := func(predicate func() bool) { + t.Helper() + for !predicate() { + select { + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(time.Millisecond): + } + } + } + focus := func(xterm bool) { + t.Helper() + wait(func() bool { + select { + case state := <-listener.FocusChanges(): + return state.Known && state.XTerm == xterm + default: + return false + } + }) + } + key := func(args ...string) { + t.Helper() + if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("key injection: %v %s", err, out) + } + } + command := func(want input.Command) { + t.Helper() + select { + case got := <-listener.Commands(): + if got.Command != want { + t.Fatalf("command = %s, want %s", got.Command, want) + } + case <-ctx.Done(): + t.Fatal("reader command did not arrive") + } + } + focus(false) + if scenario.learn { + key("key", "Insert+h") + command(input.ToggleLearnMode) + } else { + if scenario.held { + defer exec.Command("xdotool", "keyup", "KP_Begin").Run() + key("keydown", "KP_Begin") + } else { + key("key", "KP_Begin") + } + command(input.ReviewCurrentWord) + if !scenario.held { + wait(func() bool { return !listener.CommandGrabActive() }) + } + } + action(map[string]string{"action": "class", "name": "XTerm"}) + focus(true) + if scenario.held { + if !listener.CommandGrabActive() { + t.Fatal("handoff released the keyboard before the command key") + } + key("keyup", "KP_Begin") + } + wait(func() bool { return !listener.commandsApplied.Load() }) + if !scenario.learn { + listener.SetCommandGrabs(true) + } + // Let several native focus polls see the unchanged xterm. A late + // accessibility resume must not reacquire any passive keypad grab. + time.Sleep(3 * xtermFocusCheckInterval) + for _, unknown := range []bool{false, true} { + if unknown { + action(map[string]string{"action": "unknown"}) + time.Sleep(2 * xtermFocusCheckInterval) + } + action(map[string]string{"action": "clear"}) + key("key", "KP_Begin") + keys := action(map[string]string{"action": "state"}) + if len(keys) != 2 || keys[0].Symbol != 0xff9d || !keys[0].Pressed || keys[1].Symbol != 0xff9d || keys[1].Pressed { + t.Fatalf("xterm keypad press/release = %+v; unknown=%t, grabs=%t, active=%t", keys, unknown, listener.CommandGrabsEnabled(), listener.CommandGrabActive()) + } + select { + case got := <-listener.Commands(): + t.Fatalf("xterm key became reader command: %+v", got) + default: + } + } + action(map[string]string{"action": "class", "name": "example"}) + action(map[string]string{"action": "activate"}) + focus(false) + wait(listener.CommandGrabsEnabled) + key("key", "KP_Begin") + command(input.ReviewCurrentWord) + wait(func() bool { return !listener.CommandGrabActive() }) + }) + } +} diff --git a/internal/preferences/applications.go b/internal/preferences/applications.go new file mode 100644 index 0000000..6fa3c61 --- /dev/null +++ b/internal/preferences/applications.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#cgo pkg-config: atspi-2 +#include +#include "native_applications.h" +*/ +import "C" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +type applicationIdentity struct { + name string + pid int +} + +func application_choices(accessible []applicationIdentity, windows []xorg.ApplicationWindow) []string { + names := make(map[string]bool) + byPID := make(map[int]string) + add := func(name string) { + name = strings.TrimSpace(name) + if name != "" && !strings.HasPrefix(strings.ToLower(name), "cthulhu") { + names[name] = true + } + } + for _, application := range accessible { + add(application.name) + if application.pid != 0 { + byPID[application.pid] = application.name + } + } + for _, window := range windows { + name := window.Application + if !window.Wine && byPID[window.PID] != "" { + name = byPID[window.PID] + } + add(name) + } + var choices []string + for name := range names { + choices = append(choices, name) + } + sort.Slice(choices, func(i, j int) bool { return strings.ToLower(choices[i]) < strings.ToLower(choices[j]) }) + return choices +} + +func running_applications() []string { + var count C.int + native := C.prefs_application_names(&count) + defer C.prefs_free_application_names(native, count) + var accessible []applicationIdentity + for _, application := range unsafe.Slice(native, int(count)) { + name := C.GoString(application.name) + if strings.TrimSpace(name) == "" && application.pid != 0 { + // Match the reader's process-name fallback for nameless providers. + if content, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", application.pid)); err == nil { + name = strings.TrimSpace(string(content)) + } + } + accessible = append(accessible, applicationIdentity{name, int(application.pid)}) + } + reader := xorg.OpenForegroundReader() + defer reader.Close() + return application_choices(accessible, reader.ApplicationWindows()) +} + +// The chooser shares the preferences child and bounded pipe protocol. Cancel +// emits no request, so it cannot open a profile or submit settings. +func choose_application(ctx context.Context, updates <-chan Response, errorsIn <-chan error, output io.Writer) (Response, bool, error) { + choices := running_applications() + if C.prefs_application_chooser_init() == 0 { + return Response{}, false, errors.New("cannot open application chooser on this display") + } + defer C.prefs_application_chooser_close() + for _, choice := range choices { + name := C.CString(choice) + C.prefs_application_chooser_add(name) + C.free(unsafe.Pointer(name)) + } + C.prefs_application_chooser_present() + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + selected := false + for { + select { + case <-ctx.Done(): + return Response{}, false, ctx.Err() + case err := <-errorsIn: + return Response{}, false, err + case response := <-updates: + if response.Present { + C.prefs_application_chooser_present() + } else if selected { + return response, true, nil + } + case <-ticker.C: + C.prefs_application_chooser_iterate() + switch C.prefs_application_chooser_action() { + case 2: + return Response{}, false, nil + case 1: + if selected { + continue + } + name := C.prefs_application_chooser_selected() + if name == nil { + continue + } + application := C.GoString(name) + C.free(unsafe.Pointer(name)) + if err := json.NewEncoder(output).Encode(Request{Application: application}); err != nil { + return Response{}, false, err + } + selected = true + } + } + } +} diff --git a/internal/preferences/applications_test.go b/internal/preferences/applications_test.go new file mode 100644 index 0000000..7e617af --- /dev/null +++ b/internal/preferences/applications_test.go @@ -0,0 +1,23 @@ +package preferences + +import ( + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/platform/xorg" +) + +func TestApplicationChoicesUseProfileIdentityAndWineChildren(t *testing.T) { + accessible := []applicationIdentity{{"Example Browser", 1}, {"Cthulhu Preferences", 2}, {"Example Terminal", 3}, {"Unknown PID Application", 0}, {"Other Unknown PID Application", 0}} + windows := []xorg.ApplicationWindow{ + {Application: "browser-instance", PID: 1}, + {Application: "browser-second-window", PID: 1}, + {Application: "Game.exe", PID: 1, Wine: true}, + {Application: "Game.exe", Wine: true}, + {Application: "Example Editor", PID: 4}, + } + want := []string{"Example Browser", "Example Editor", "Example Terminal", "Game.exe", "Other Unknown PID Application", "Unknown PID Application"} + if got := application_choices(accessible, windows); !reflect.DeepEqual(got, want) { + t.Fatalf("applications = %v, want %v", got, want) + } +} diff --git a/internal/preferences/bindings.go b/internal/preferences/bindings.go new file mode 100644 index 0000000..5ea1fcf --- /dev/null +++ b/internal/preferences/bindings.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "strings" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +var modifierKeys = []string{"Insert", "KP_Insert", "Caps_Lock"} + +func (session *uiSession) setKeyBindings(values Values) { + session.bindingDraft = input.CloneBindingOverrides(values.KeyBindings) + setCheck("generalDesktopButton", values.KeyboardLayout != 2) + setCheck("generalLaptopButton", values.KeyboardLayout == 2) + setModifierKeys(values.CthulhuModifierKeys) + session.bindingLayout = values.KeyboardLayout + if session.bindingLayout != 2 { + session.bindingLayout = 1 + } + setCheck("keyBindingDesktopButton", session.bindingLayout == 1) + setCheck("keyBindingLaptopButton", session.bindingLayout == 2) + session.populateKeyBindings(0) +} + +func (session *uiSession) refreshKeyBindingLayout() { + layout := 1 + if getCheck("keyBindingLaptopButton") { + layout = 2 + } + if layout != session.bindingLayout && (layout == 1 || layout == 2) { + session.bindingLayout = layout + session.populateKeyBindings(0) + } +} + +func (session *uiSession) populateKeyBindings(selected int) { + session.bindingRows = input.BindingRows(input.KeyboardLayout(session.bindingLayout), session.bindingDraft) + C.prefs_bindings_clear() + for _, row := range session.bindingRows { + scope := "Global" + if row.Scope == "browse" { + scope = "Browse mode" + } + keys := row.Keys + if keys == "" { + keys = "Unbound" + } + label, scopeText, shortcuts, defaults := C.CString(row.Label), C.CString(scope), C.CString(keys), C.CString(row.DefaultKeys) + C.prefs_binding_append(label, scopeText, shortcuts, defaults) + C.free(unsafe.Pointer(label)) + C.free(unsafe.Pointer(scopeText)) + C.free(unsafe.Pointer(shortcuts)) + C.free(unsafe.Pointer(defaults)) + } + C.prefs_binding_select(C.int(selected)) + // Keep the editor empty when the selected command has been disabled. + if selected >= 0 && selected < len(session.bindingRows) { + id, text := C.CString("keyBindingEntry"), C.CString(session.bindingRows[selected].Keys) + C.prefs_entry_set(id, text) + C.free(unsafe.Pointer(id)) + C.free(unsafe.Pointer(text)) + } +} + +func (session *uiSession) editKeyBinding(action int) { + selected := int(C.prefs_binding_selected()) + if action != 7 && (selected < 0 || selected >= len(session.bindingRows)) { + return + } + draft := input.CloneBindingOverrides(session.bindingDraft) + var row input.BindingRow + if selected >= 0 && selected < len(session.bindingRows) { + row = session.bindingRows[selected] + } + kept := draft[:0] + for _, override := range draft { + matches := override.Layout == input.KeyboardLayout(session.bindingLayout) && (action == 7 || (override.Scope == row.Scope && override.Command == row.Command)) + if !matches { + kept = append(kept, override) + } + } + draft = kept + if action == 4 || action == 5 { + keys := []string{} + if action == 4 { + id := C.CString("keyBindingEntry") + text := C.GoString(C.prefs_entry_get(id)) + C.free(unsafe.Pointer(id)) + if strings.TrimSpace(text) != "" { + for _, key := range strings.Split(text, ";") { + chord, err := input.ParseCommandChord(key) + if err != nil { + status(err.Error(), true) + return + } + keys = append(keys, input.FormatCommandChord(chord)) + } + } + } + draft = append(draft, input.BindingOverride{Layout: input.KeyboardLayout(session.bindingLayout), Scope: row.Scope, Command: row.Command, Keys: keys}) + } + if err := input.ValidateBindingOverrides(draft); err != nil { + status(err.Error(), true) + return + } + session.bindingDraft = draft + session.populateKeyBindings(selected) + status("Binding updated. Apply or OK commits these changes.", true) +} + +func setModifierKeys(keys []string) { + for _, key := range modifierKeys { + enabled := false + for _, configured := range keys { + if configured == key { + enabled = true + break + } + } + id := C.CString(key) + C.prefs_modifier_set(id, boolean(enabled)) + C.free(unsafe.Pointer(id)) + } +} + +func getModifierKeys() []string { + keys := []string{} + for _, key := range modifierKeys { + id := C.CString(key) + if C.prefs_modifier_get(id) != 0 { + keys = append(keys, key) + } + C.free(unsafe.Pointer(id)) + } + return keys +} diff --git a/internal/preferences/braille.go b/internal/preferences/braille.go new file mode 100644 index 0000000..e2f25f3 --- /dev/null +++ b/internal/preferences/braille.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "path/filepath" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func setBrailleChoice(brief, verbose string, value int) { + setCheck(brief, value == 0) + setCheck(verbose, value != 0) +} + +func setBrailleIndicator(prefix string, value int) { + suffix := map[int]string{0: "NoneButton", 0x40: "7Button", 0x80: "8Button", 0xc0: "BothButton"}[value] + setCheck(prefix+suffix, true) +} + +func getBrailleIndicator(prefix string) int { + for _, option := range []struct { + suffix string + dots int + }{ + {"7Button", 0x40}, {"8Button", 0x80}, {"BothButton", 0xc0}, + } { + if getCheck(prefix + option.suffix) { + return option.dots + } + } + return 0 +} + +func (session *uiSession) set_braille_values(values Values) { + setCheck("enableBrailleCheckButton", values.EnableBraille) + setCheck("contractedBrailleCheckButton", values.EnableContractedBraille) + setCheck("abbrevRolenames", values.Braille.AbbreviateRoles) + setCheck("disableBrailleEOLCheckButton", values.Braille.DisableEOL) + setCheck("enableBrailleWordWrapCheckButton", values.Braille.WordWrap) + setCheck("enableFlashMessagesCheckButton", values.Braille.FlashMessages) + setCheck("flashIsPersistentCheckButton", values.Braille.FlashPersistent) + setCheck("flashIsDetailedCheckButton", values.Braille.FlashDetailed) + setSpin("brailleFlashTimeSpinButton", float64(values.Braille.FlashTime)/1000) + setBrailleChoice("brailleBriefButton", "brailleVerboseButton", values.Braille.Verbosity) + setBrailleIndicator("brailleSelection", values.Braille.SelectionIndicator) + setBrailleIndicator("brailleLink", values.Braille.LinkIndicator) + session.contractionTables = braille.AvailableTables() + comboID := C.CString("contractionTableCombo") + C.prefs_combo_clear(comboID) + defaultLabel := C.CString("Automatic for current language") + C.prefs_combo_append(comboID, defaultLabel) + C.free(unsafe.Pointer(defaultLabel)) + selected := 0 + for index, table := range session.contractionTables { + label := C.CString(table.Label) + C.prefs_combo_append(comboID, label) + C.free(unsafe.Pointer(label)) + if table.File == values.BrailleContractionTable || filepath.Base(table.File) == values.BrailleContractionTable { + selected = index + 1 + } + } + if values.BrailleContractionTable != "" && selected == 0 { + // Keep a saved table available even after a locale change or package update. + session.contractionTables = append(session.contractionTables, braille.TableOption{ + Label: "Saved: " + filepath.Base(values.BrailleContractionTable), + File: values.BrailleContractionTable, + }) + label := C.CString(session.contractionTables[len(session.contractionTables)-1].Label) + C.prefs_combo_append(comboID, label) + C.free(unsafe.Pointer(label)) + selected = len(session.contractionTables) + } + C.prefs_combo_set(comboID, C.int(selected)) + C.free(unsafe.Pointer(comboID)) +} + +func (session *uiSession) get_braille_values(values *Values) error { + values.EnableBraille = getCheck("enableBrailleCheckButton") + values.EnableContractedBraille = getCheck("contractedBrailleCheckButton") + values.Braille.AbbreviateRoles = getCheck("abbrevRolenames") + values.Braille.DisableEOL = getCheck("disableBrailleEOLCheckButton") + values.Braille.WordWrap = getCheck("enableBrailleWordWrapCheckButton") + values.Braille.FlashMessages = getCheck("enableFlashMessagesCheckButton") + values.Braille.FlashPersistent = getCheck("flashIsPersistentCheckButton") + values.Braille.FlashDetailed = getCheck("flashIsDetailedCheckButton") + values.Braille.FlashTime = int(getSpin("brailleFlashTimeSpinButton") * 1000) + if getCheck("brailleVerboseButton") { + values.Braille.Verbosity = 1 + } + values.Braille.SelectionIndicator = getBrailleIndicator("brailleSelection") + values.Braille.LinkIndicator = getBrailleIndicator("brailleLink") + selectedTable := getCombo("contractionTableCombo") + if selectedTable < 0 || selectedTable > len(session.contractionTables) { + return errors.New("Choose a contraction table.") + } + if selectedTable > 0 { + values.BrailleContractionTable = session.contractionTables[selectedTable-1].File + } + return nil +} diff --git a/internal/preferences/catalog_protocol.go b/internal/preferences/catalog_protocol.go new file mode 100644 index 0000000..cad03fd --- /dev/null +++ b/internal/preferences/catalog_protocol.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "sort" + + "git.stormux.org/storm/cthulhu/internal/speech" +) + +const initialMessageLimit = 512 * 1024 + +type catalogPart struct { + Catalog speech.Catalog + CatalogPending bool +} + +// EncodeInitial keeps each protocol line bounded without removing voices or +// languages. The helper assembles the catalog before constructing GTK controls. +func EncodeInitial(encoder *json.Encoder, response Response) error { + encoded, err := json.Marshal(response) + if err != nil { + return err + } + if len(encoded)+1 <= initialMessageLimit { + return encoder.Encode(json.RawMessage(encoded)) + } + catalog := response.Catalog + response.Catalog = nil + response.CatalogPending = true + encoded, err = json.Marshal(response) + if err != nil { + return err + } + if len(encoded)+1 > initialMessageLimit { + return fmt.Errorf("preferences settings exceed %d-byte protocol limit", initialMessageLimit) + } + if err := encoder.Encode(json.RawMessage(encoded)); err != nil { + return err + } + modules := make([]string, 0, len(catalog)) + for module := range catalog { + modules = append(modules, module) + } + sort.Strings(modules) + for _, module := range modules { + voices := catalog[module] + for { + count := min(len(voices), 128) + for { + encoded, err = json.Marshal(catalogPart{ + Catalog: speech.Catalog{module: voices[:count]}, CatalogPending: true, + }) + if err != nil { + return err + } + if len(encoded)+1 <= initialMessageLimit { + break + } + if count <= 1 { + return fmt.Errorf("preferences voice in module %q exceeds %d-byte protocol limit", module, initialMessageLimit) + } + count /= 2 + } + if err := encoder.Encode(json.RawMessage(encoded)); err != nil { + return err + } + voices = voices[count:] + if len(voices) == 0 { + break + } + } + } + return encoder.Encode(catalogPart{}) +} + +// read_responses owns catalog assembly for the lifetime of the helper pipe. +// Only complete responses reach GTK, including after application selection. +func read_responses(ctx context.Context, input io.Reader, updates chan<- Response, errorsIn chan<- error) { + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 4096), 1024*1024) + var initial *Response + for scanner.Scan() { + var response Response + if err := json.Unmarshal(scanner.Bytes(), &response); err != nil { + errorsIn <- err + return + } + if initial != nil { + if initial.Catalog == nil { + initial.Catalog = make(speech.Catalog) + } + for module, voices := range response.Catalog { + initial.Catalog[module] = append(initial.Catalog[module], voices...) + } + if response.CatalogPending { + continue + } + response = *initial + response.CatalogPending = false + initial = nil + } else if response.CatalogPending { + initial = &response + continue + } + select { + case updates <- response: + case <-ctx.Done(): + return + } + } + err := scanner.Err() + if err == nil && initial != nil { + err = io.ErrUnexpectedEOF + } + errorsIn <- err +} diff --git a/internal/preferences/catalog_protocol_test.go b/internal/preferences/catalog_protocol_test.go new file mode 100644 index 0000000..fb2ca31 --- /dev/null +++ b/internal/preferences/catalog_protocol_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/speech" +) + +func TestInitialCatalogRoundTrip(t *testing.T) { + for _, size := range []struct{ count, nameBytes int }{{1, 100}, {6000, 100}, {4, 250000}} { + count := size.count + catalog := speech.Catalog{ + "espeak-ng": make([]speech.Voice, count), + "other": {{Name: "Other voice", Language: "es"}}, + "empty": nil, + } + for index := range catalog["espeak-ng"] { + catalog["espeak-ng"][index] = speech.Voice{Name: strings.Repeat("v", size.nameBytes), Language: "de"} + } + // The last entry must survive even when it lies beyond the old cutoff. + catalog["espeak-ng"][count-1] = speech.Voice{Name: "English voice", Language: "en"} + initial := Response{Catalog: catalog, Message: "Preferences ready.", Application: "Example", CanSave: true, Accepted: true} + var wire bytes.Buffer + encoder := json.NewEncoder(&wire) + if err := encoder.Encode(Response{ChooseApplication: true}); err != nil { + t.Fatal(err) + } + if err := EncodeInitial(encoder, initial); err != nil { + t.Fatal(err) + } + for _, line := range bytes.Split(bytes.TrimSpace(wire.Bytes()), []byte{'\n'}) { + if len(line)+1 > initialMessageLimit { + t.Fatalf("protocol line has %d bytes", len(line)+1) + } + } + // Reopening after the chooser uses the same response reader. Subsequent + // present requests must not be absorbed into the assembled catalog. + if err := encoder.Encode(Response{Present: true}); err != nil { + t.Fatal(err) + } + updates := make(chan Response, 3) + errorsIn := make(chan error, 1) + read_responses(t.Context(), &wire, updates, errorsIn) + if err := <-errorsIn; err != nil { + t.Fatal(err) + } + if got := <-updates; !got.ChooseApplication { + t.Fatal("application chooser response was mixed into the catalog") + } + if got := <-updates; !reflect.DeepEqual(got, initial) { + t.Fatalf("catalog or session metadata changed for %d voices", count) + } + if got := <-updates; !got.Present || got.Catalog != nil { + t.Fatal("present response was mixed into the initial catalog") + } + } +} + +func TestInitialCatalogRejectsOversizedEntry(t *testing.T) { + for _, initial := range []Response{ + {Message: strings.Repeat("x", initialMessageLimit)}, + {Catalog: speech.Catalog{"espeak-ng": {{Name: strings.Repeat("x", initialMessageLimit)}}}}, + } { + var wire bytes.Buffer + if err := EncodeInitial(json.NewEncoder(&wire), initial); err == nil { + t.Fatal("oversized settings or voice accepted") + } + } +} + +func TestInitialCatalogIncompleteAndCancellation(t *testing.T) { + updates := make(chan Response, 1) + errorsIn := make(chan error, 1) + read_responses(t.Context(), strings.NewReader(`{"CatalogPending":true}`+"\n"), updates, errorsIn) + if err := <-errorsIn; !errors.Is(err, io.ErrUnexpectedEOF) || len(updates) != 0 { + t.Fatalf("incomplete catalog reached the UI: %v", err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + read_responses(ctx, strings.NewReader("{}\n"), make(chan Response), errorsIn) +} + +func TestDefaultVoicesFiltersESpeakAndDefaultAlias(t *testing.T) { + t.Setenv("LC_ALL", "en_US.UTF-8") + voices := []speech.Voice{{Name: "German", Language: "de"}, {Name: "English", Language: "en-US"}} + session := &uiSession{catalog: speech.Catalog{"": voices, "espeak-ng": voices, "other": voices}} + for _, module := range []string{"", "espeak-ng"} { + if got := session.default_voices(module); !reflect.DeepEqual(got, voices[1:]) { + t.Fatalf("module %q: %v", module, got) + } + } + if got := session.default_voices("other"); !reflect.DeepEqual(got, voices) { + t.Fatal("other synthesizer was filtered") + } + if !reflect.DeepEqual(session.catalog["espeak-ng"], voices) { + t.Fatal("language selection lost the full catalog") + } + session.catalog[""] = voices[:1] + if got := session.default_voices(""); !reflect.DeepEqual(got, voices[:1]) { + t.Fatal("unidentified default synthesizer was filtered") + } +} diff --git a/internal/preferences/catalog_voices.go b/internal/preferences/catalog_voices.go new file mode 100644 index 0000000..53179ce --- /dev/null +++ b/internal/preferences/catalog_voices.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "slices" + "strings" + + "git.stormux.org/storm/cthulhu/internal/speech" +) + +// default_voices filters eSpeak's language/variant combinations for controls +// without an explicit language selection. Saved voices remain available through +// the caller's selected-family entry, and the full language catalog is retained. +func (session *uiSession) default_voices(module string) []speech.Voice { + voices := session.catalog[module] + if strings.Contains(strings.ToLower(module), "espeak") { + return speech.LocaleVoices(voices) + } + if module == "" { + // Speech Dispatcher also lists the default synthesizer under an empty + // module name. Match its catalog to identify eSpeak without guessing. + for name, candidates := range session.catalog { + if strings.Contains(strings.ToLower(name), "espeak") && slices.Equal(voices, candidates) { + return speech.LocaleVoices(voices) + } + } + } + return voices +} diff --git a/internal/preferences/echo.go b/internal/preferences/echo.go new file mode 100644 index 0000000..b12c37a --- /dev/null +++ b/internal/preferences/echo.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "sort" + "strings" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func echoChecks(values *Values) []struct { + id string + value *bool +} { + return []struct { + id string + value *bool + }{ + {"keyEchoCheckButton", &values.EnableKeyEcho}, + {"enableAlphabeticKeysCheckButton", &values.EnableAlphabeticKeys}, + {"enableNumericKeysCheckButton", &values.EnableNumericKeys}, + {"enablePunctuationKeysCheckButton", &values.EnablePunctuationKeys}, + {"enableSpaceCheckButton", &values.EnableSpace}, + {"enableModifierKeysCheckButton", &values.EnableModifierKeys}, + {"enableFunctionKeysCheckButton", &values.EnableFunctionKeys}, + {"enableActionKeysCheckButton", &values.EnableActionKeys}, + {"enableNavigationKeysCheckButton", &values.EnableNavigationKeys}, + {"enableDiacriticalKeysCheckButton", &values.EnableDiacriticalKeys}, + {"enableEchoByCharacterCheckButton", &values.EnableEchoByCharacter}, + {"enableEchoByWordCheckButton", &values.EnableEchoByWord}, + {"enableEchoBySentenceCheckButton", &values.EnableEchoBySentence}, + {"useCustomEchoVoiceCheckButton", &values.UseCustomEchoVoice}, + {"useCustomEchoSpeechServerCheckButton", &values.UseCustomEchoSpeechServer}, + {"useCustomEchoForKeyCheckButton", &values.UseCustomEchoForKey}, + {"useCustomEchoForCharacterCheckButton", &values.UseCustomEchoForCharacter}, + {"useCustomEchoForWordCheckButton", &values.UseCustomEchoForWord}, + {"useCustomEchoForSentenceCheckButton", &values.UseCustomEchoForSentence}, + } +} + +func (session *uiSession) setEchoVoice(values Values) { + session.echoModules = []string{""} + for module := range session.catalog { + if module != "" { + session.echoModules = append(session.echoModules, module) + } + } + sort.Strings(session.echoModules[1:]) + selected := 0 + if values.EchoSpeechServer != "" { + selected = -1 + for i, module := range session.echoModules { + if module == values.EchoSpeechServer { + selected = i + } + } + if selected < 0 { + selected = len(session.echoModules) + session.echoModules = append(session.echoModules, values.EchoSpeechServer) + } + } + labels := append([]string(nil), session.echoModules...) + labels[0] = "Default" + comboItems("echoSpeechServers", labels, selected) + // The toggle state is installed by setValues before the next GTK iteration. + module := "" + if values.UseCustomEchoSpeechServer { + module = values.EchoSpeechServer + } + session.populateEchoFamilies(module, values.EchoVoice.Family) + for _, item := range []struct { + id string + value *float64 + fallback float64 + }{ + {"echoRateScale", values.EchoVoice.Rate, voiceFallback(values.Rate, 50)}, + {"echoPitchScale", values.EchoVoice.AveragePitch, voiceFallback(values.Pitch, 5)}, + {"echoVolumeScale", values.EchoVoice.Gain, voiceFallback(values.Volume, 10)}, + } { + id := C.CString(item.id) + C.prefs_range_set(id, C.double(voiceFallback(item.value, item.fallback))) + C.free(unsafe.Pointer(id)) + } +} + +func voiceFallback(value *float64, fallback float64) float64 { + if value != nil { + return *value + } + return fallback +} + +func (session *uiSession) populateEchoFamilies(module string, selected *config.VoiceFamily) { + session.activeEchoModule = module + session.echoFamilies = []*config.VoiceFamily{nil} + labels := []string{"Default"} + index := 0 + for _, voice := range session.default_voices(module) { + family := &config.VoiceFamily{Name: voice.Name, Lang: voice.Language, Variant: voice.Variant} + session.echoFamilies = append(session.echoFamilies, family) + labels = append(labels, voice.Name+" ("+voice.Language+")") + if selected != nil && selected.Name == voice.Name && strings.EqualFold(strings.TrimSuffix(selected.Lang+"-"+selected.Dialect, "-"), voice.Language) { + index = len(labels) - 1 + } + } + if selected != nil && index == 0 { + index = len(labels) + session.echoFamilies = append(session.echoFamilies, selected) + labels = append(labels, selected.Name+" ("+selected.Lang+")") + } + comboItems("echoSpeechFamilies", labels, index) +} + +func (session *uiSession) selectedEchoModule() string { + i := getCombo("echoSpeechServers") + if i >= 0 && i < len(session.echoModules) { + return session.echoModules[i] + } + return "" +} + +func (session *uiSession) refreshEchoVoices() { + module := "" + if getCheck("useCustomEchoSpeechServerCheckButton") { + module = session.selectedEchoModule() + } + if module != session.activeEchoModule { + session.populateEchoFamilies(module, nil) + } +} + +func (session *uiSession) getEchoVoice() (config.Voice, string) { + var voice config.Voice + for _, item := range []struct { + id string + target **float64 + }{ + {"echoRateScale", &voice.Rate}, {"echoPitchScale", &voice.AveragePitch}, {"echoVolumeScale", &voice.Gain}, + } { + id := C.CString(item.id) + value := float64(C.prefs_number_value(id)) + C.free(unsafe.Pointer(id)) + *item.target = &value + } + i := getCombo("echoSpeechFamilies") + if i >= 0 && i < len(session.echoFamilies) { + voice.Family = session.echoFamilies[i] + } + return voice, session.selectedEchoModule() +} + +func set_echo_checks(values Values) { + for _, check := range echoChecks(&values) { + setCheck(check.id, *check.value) + } +} + +func get_echo_checks(values *Values) { + for _, check := range echoChecks(values) { + *check.value = getCheck(check.id) + } +} diff --git a/internal/preferences/general.go b/internal/preferences/general.go new file mode 100644 index 0000000..e2731db --- /dev/null +++ b/internal/preferences/general.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "time" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/datetime" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func setFormatCombo(name string, options []datetime.Option, value string) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_combo_clear(id) + for _, option := range options { + text := C.CString(option.Label) + C.prefs_combo_append(id, text) + C.free(unsafe.Pointer(text)) + } + C.prefs_combo_set(id, C.int(formatIndex(options, value))) +} + +func formatIndex(options []datetime.Option, value string) int { + for index, option := range options { + if option.Value == value { + return index + } + } + return 0 +} + +func (session *uiSession) set_general_values(values Values) { + session.progressBar = values.ProgressBar + session.setKeyBindings(values) + setCombo("progressBarOutputCombo", map[string]int{"off": 0, "beep": 1, "speak": 2, "both": 3}[values.ProgressBar.OutputMode]) + setCheck("reportBackgroundProgressBarsCheckButton", values.ProgressBar.ReportBackground) + now := time.Now() + setFormatCombo("timeFormatCombo", datetime.TimeOptions(now), values.TimeFormat) + setFormatCombo("dateFormatCombo", datetime.DateOptions(now), values.DateFormat) +} + +func (session *uiSession) get_general_values(values *Values) error { + values.KeyBindings = input.CloneBindingOverrides(session.bindingDraft) + values.KeyboardLayout = 1 + if getCheck("generalLaptopButton") { + values.KeyboardLayout = 2 + } + values.CthulhuModifierKeys = getModifierKeys() + ti, di := getCombo("timeFormatCombo"), getCombo("dateFormatCombo") + now := time.Now() + timeOptions := datetime.TimeOptions(now) + dateOptions := datetime.DateOptions(now) + if ti < 0 || ti >= len(timeOptions) || di < 0 || di >= len(dateOptions) { + return errors.New("Choose a time and date format.") + } + mode := getCombo("progressBarOutputCombo") + if mode < 0 || mode > 3 { + return errors.New("Choose a progress bar output mode.") + } + // Percentage intervals are configured in TOML, so retain them when the + // visible mode and background controls are applied or previewed. + values.ProgressBar = session.progressBar + values.ProgressBar.OutputMode = []string{"off", "beep", "speak", "both"}[mode] + values.ProgressBar.ReportBackground = getCheck("reportBackgroundProgressBarsCheckButton") + values.TimeFormat = timeOptions[ti].Value + values.DateFormat = dateOptions[di].Value + return nil +} diff --git a/internal/preferences/native.c b/internal/preferences/native.c new file mode 100644 index 0000000..8a99d1e --- /dev/null +++ b/internal/preferences/native.c @@ -0,0 +1,280 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +static GtkBuilder *builder; +static int action; +static int voice_changed; +// Borrowed from the builder for the duration of one Apply/OK request. +static GtkWidget *busy_focus; +void prefs_set_action(int value) { action = value; } +GtkWidget *prefs_widget(const char *id) { return GTK_WIDGET(gtk_builder_get_object(builder, id)); } +static void name_accessible_tree(AtkObject *object, const char *name) { + const char *current = atk_object_get_name(object); + if (!current || !current[0]) atk_object_set_name(object, name); + int children = atk_object_get_n_accessible_children(object); + for (int i = 0; i < children; i++) { + AtkObject *child = atk_object_ref_accessible_child(object, i); + if (!child) continue; + name_accessible_tree(child, name); + g_object_unref(child); + } +} +static void name_widget(const char *id, const char *name) { + name_accessible_tree(gtk_widget_get_accessible(prefs_widget(id)), name); +} +static void describe_combo_tree(AtkObject *object, const char *name, const char *value) { + AtkRole role = atk_object_get_role(object); + if (role == ATK_ROLE_COMBO_BOX || role == ATK_ROLE_PUSH_BUTTON || + role == ATK_ROLE_TOGGLE_BUTTON) { + atk_object_set_name(object, name); + atk_object_set_description(object, value); + } + int children = atk_object_get_n_accessible_children(object); + for (int i = 0; i < children; i++) { + AtkObject *child = atk_object_ref_accessible_child(object, i); + if (!child) continue; + describe_combo_tree(child, name, value); + g_object_unref(child); + } +} +static void combo_changed(GtkComboBox *combo, gpointer data) { + gchar *value = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(combo)); + describe_combo_tree(gtk_widget_get_accessible(GTK_WIDGET(combo)), + (const char *)data, value ? value : ""); + g_free(value); +} +static void free_signal_data(gpointer data, GClosure *closure) { + (void)closure; + g_free(data); +} +static void set_focus_chain(const char *container_id, const char *ids[], unsigned int count) { + GList *chain = NULL; + for (unsigned int i = 0; i < count; i++) chain = g_list_append(chain, prefs_widget(ids[i])); + G_GNUC_BEGIN_IGNORE_DEPRECATIONS + gtk_container_set_focus_chain(GTK_CONTAINER(prefs_widget(container_id)), chain); + G_GNUC_END_IGNORE_DEPRECATIONS + g_list_free(chain); +} +static void clicked(GtkButton *button, gpointer data) { + action = GPOINTER_TO_INT(data); + if (action == 1 || action == 2) busy_focus = GTK_WIDGET(button); +} +static gboolean closing(GtkWidget *window, GdkEvent *event, gpointer data) { action = 3; return TRUE; } +static gboolean key(GtkWidget *window, GdkEventKey *event, gpointer data) { + if (event->keyval == GDK_KEY_Escape) { action = 3; return TRUE; } + return FALSE; +} +static void defaults(GtkToggleButton *button, gpointer spin) { + gtk_widget_set_sensitive(GTK_WIDGET(spin), !gtk_toggle_button_get_active(button)); +} +static void voice_value_changed(GtkWidget *control, gpointer data) { + (void)control; (void)data; + voice_changed = 1; +} +static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gpointer data) { + (void)data; + if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE; + double step; + switch (event->keyval) { + case GDK_KEY_Up: case GDK_KEY_KP_Up: step = 1.0; break; + case GDK_KEY_Down: case GDK_KEY_KP_Down: step = -1.0; break; + case GDK_KEY_Right: case GDK_KEY_KP_Right: step = 0.1; break; + case GDK_KEY_Left: case GDK_KEY_KP_Left: step = -0.1; break; + default: return FALSE; + } + // GtkRange clamps at the adjustment's bounds and emits value-changed, + // preserving accessible value updates and the existing preview path. + GtkRange *range = GTK_RANGE(control); + gtk_range_set_value(range, gtk_range_get_value(range) + step); + return TRUE; +} +static void echo_sensitivity(GtkToggleButton *button, gpointer data) { + (void)button; (void)data; + int enabled = prefs_check_get("keyEchoCheckButton"); + const char *categories[] = {"enableAlphabeticKeysCheckButton", "enableNumericKeysCheckButton", + "enablePunctuationKeysCheckButton", "enableSpaceCheckButton", "enableModifierKeysCheckButton", + "enableFunctionKeysCheckButton", "enableActionKeysCheckButton", "enableNavigationKeysCheckButton", + "enableDiacriticalKeysCheckButton"}; + for (unsigned i = 0; i < G_N_ELEMENTS(categories); i++) gtk_widget_set_sensitive(prefs_widget(categories[i]), enabled); + int custom = prefs_check_get("useCustomEchoVoiceCheckButton"); + const char *voice[] = {"useCustomEchoSpeechServerCheckButton", "echoSpeechFamilies", "echoRateScale", "echoPitchScale", "echoVolumeScale"}; + for (unsigned i = 0; i < G_N_ELEMENTS(voice); i++) gtk_widget_set_sensitive(prefs_widget(voice[i]), custom); + gtk_widget_set_sensitive(prefs_widget("echoSpeechServers"), custom && prefs_check_get("useCustomEchoSpeechServerCheckButton")); + gtk_widget_set_sensitive(prefs_widget("useCustomEchoForKeyCheckButton"), custom && enabled); + gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton")); + gtk_widget_set_sensitive(prefs_widget("useCustomEchoForWordCheckButton"), custom && prefs_check_get("enableEchoByWordCheckButton")); + gtk_widget_set_sensitive(prefs_widget("useCustomEchoForSentenceCheckButton"), custom && prefs_check_get("enableEchoBySentenceCheckButton")); +} +static void sound_sensitivity(GtkToggleButton *button, gpointer data) { + (void)button; (void)data; + int enabled = prefs_check_get("enableSoundCheckButton"); + const char *controls[] = {"soundVolumeScale", "soundSinkCombo", "soundThemeCombo", + "roleSoundPresentationCombo", "playSoundForRoleCheckButton", "playSoundForStateCheckButton", + "playSoundForPositionInSetCheckButton", "spatializeObjectSoundsCheckButton"}; + for (unsigned i = 0; i < G_N_ELEMENTS(controls); i++) gtk_widget_set_sensitive(prefs_widget(controls[i]), enabled); +} +static void ocr_sensitivity(GtkToggleButton *button, gpointer data) { + (void)button; (void)data; + gtk_widget_set_sensitive(prefs_widget("ocrBlackWhiteValueSpinButton"), prefs_check_get("ocrBlackWhiteCheckButton")); + gtk_widget_set_sensitive(prefs_widget("ocrRefreshIntervalSpinButton"), prefs_check_get("ocrAutoRefreshCheckButton")); +} +int prefs_init(const char *xml) { + if (!gtk_init_check(NULL, NULL)) return 0; + g_set_application_name("Cthulhu Preferences"); + builder = gtk_builder_new(); + GError *error = NULL; + if (!gtk_builder_add_from_string(builder, xml, -1, &error)) { + if (error) g_error_free(error); + g_object_unref(builder); builder = NULL; + return 0; + } + prefs_init_reading_settings(); + prefs_init_key_bindings(); + prefs_init_modifiers(); + prefs_init_pronunciation(); + prefs_init_text_attributes(); + prefs_init_tentacles(); + AtkObject *reading_accessible = gtk_widget_get_accessible(prefs_widget("readingSettingsTreeview")); + atk_object_set_name(reading_accessible, "Reading settings"); + atk_object_set_description(reading_accessible, "Use arrow keys to move between settings and Space to toggle the selected setting."); + g_signal_connect(prefs_widget("apply"), "clicked", G_CALLBACK(clicked), GINT_TO_POINTER(1)); + g_signal_connect(prefs_widget("save"), "clicked", G_CALLBACK(clicked), GINT_TO_POINTER(2)); + g_signal_connect(prefs_widget("cancel"), "clicked", G_CALLBACK(clicked), GINT_TO_POINTER(3)); + g_signal_connect(prefs_widget("window"), "delete-event", G_CALLBACK(closing), NULL); + g_signal_connect(prefs_widget("window"), "key-press-event", G_CALLBACK(key), NULL); + const char *ids[] = {"rate", "pitch", "volume"}; + for (int i = 0; i < 3; i++) { + gchar *id = g_strconcat(ids[i], "_default", NULL); + g_signal_connect(prefs_widget(id), "toggled", G_CALLBACK(defaults), prefs_widget(ids[i])); + g_signal_connect(prefs_widget(id), "toggled", G_CALLBACK(voice_value_changed), NULL); + g_signal_connect(prefs_widget(ids[i]), "value-changed", G_CALLBACK(voice_value_changed), NULL); + g_free(id); + } + const char *voiceScales[] = {"rate", "pitch", "volume", "echoRateScale", "echoPitchScale", "echoVolumeScale"}; + for (unsigned i = 0; i < G_N_ELEMENTS(voiceScales); i++) { + GtkWidget *control = prefs_widget(voiceScales[i]); + g_signal_connect(control, "key-press-event", G_CALLBACK(voice_scale_key_press), NULL); + atk_object_set_description(gtk_widget_get_accessible(control), + "Up and Down change by 1; Left and Right change by 0.1."); + } + const char *voiceCombos[] = { + "speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo", + "echoSpeechServers", "echoSpeechFamilies", + }; + for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) { + g_signal_connect(prefs_widget(voiceCombos[i]), "changed", + G_CALLBACK(voice_value_changed), NULL); + } + g_signal_connect(prefs_widget("hardwareDeviceEntry"), "changed", G_CALLBACK(voice_value_changed), NULL); + GtkEntryCompletion *completion = gtk_entry_completion_new(); + GtkListStore *devices = gtk_list_store_new(1, G_TYPE_STRING); + gtk_entry_completion_set_model(completion, GTK_TREE_MODEL(devices)); + gtk_entry_completion_set_text_column(completion, 0); + gtk_entry_completion_set_inline_completion(completion, TRUE); + gtk_entry_set_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry")), completion); + g_object_unref(completion); + g_object_unref(devices); + const char *labelled[] = {"rate", "pitch", "volume", "capitalization"}; + for (int i = 0; i < 4; i++) { + gchar *id = g_strconcat(labelled[i], "_label", NULL); + name_widget(labelled[i], gtk_label_get_text(GTK_LABEL(prefs_widget(id)))); + g_free(id); + } + const struct { const char *id; const char *name; } named[] = { + {"timeFormatCombo", "Time format"}, + {"dateFormatCombo", "Date format"}, + {"progressBarOutputCombo", "Progress bar output"}, + {"sayAllStyle", "Say All by"}, + {"soundVolumeScale", "Sound volume"}, + {"soundSinkCombo", "Audio backend"}, + {"soundThemeCombo", "Sound theme"}, + {"roleSoundPresentationCombo", "Element presentation"}, + {"speechSystems", "Speech system"}, + {"speechServers", "Speech synthesizer"}, + {"speechLanguages", "Language"}, + {"speechFamilies", "Person"}, + {"speechHistoryLimitSpinButton", "Speech history entries"}, + {"voiceTypesCombo", "Voice type"}, + {"capitalization", "Capitalization style"}, + {"hardwareDeviceEntry", "Serial device"}, + {"hardwareBaudCombo", "Serial baud rate"}, + {"contractionTableCombo", "Contraction table"}, + {"brailleFlashTimeSpinButton", "Flash message duration"}, + {"echoSpeechServers", "Key echo speech synthesizer"}, + {"echoSpeechFamilies", "Key echo person"}, + {"echoRateScale", "Key echo rate"}, + {"echoPitchScale", "Key echo pitch"}, + {"echoVolumeScale", "Key echo volume"}, + {"keyBindingEntry", "Shortcuts"}, + {"ocrScaleSpinButton", "OCR scale factor"}, + {"ocrBlackWhiteValueSpinButton", "Black and white threshold"}, + {"ocrLanguageEntry", "OCR recognition languages"}, + {"ocrLayoutCombo", "OCR text layout"}, + {"ocrRefreshIntervalSpinButton", "OCR refresh interval in milliseconds"}, + }; + for (unsigned int i = 0; i < G_N_ELEMENTS(named); i++) { + name_widget(named[i].id, named[i].name); + GtkWidget *control = prefs_widget(named[i].id); + if (GTK_IS_COMBO_BOX(control) && + gtk_combo_box_get_active(GTK_COMBO_BOX(control)) < 0) { + gtk_combo_box_set_active(GTK_COMBO_BOX(control), 0); + } + if (GTK_IS_COMBO_BOX_TEXT(control)) { + g_signal_connect_data(control, "changed", G_CALLBACK(combo_changed), + g_strdup(named[i].name), free_signal_data, 0); + } + } + const char *keyboard_focus[] = { + "generalDesktopButton", "generalLaptopButton", + }; + set_focus_chain("keyboard_layout_controls", keyboard_focus, + G_N_ELEMENTS(keyboard_focus)); + const char *echo_toggles[] = {"keyEchoCheckButton", "useCustomEchoVoiceCheckButton", "useCustomEchoSpeechServerCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"}; + for (unsigned i = 0; i < G_N_ELEMENTS(echo_toggles); i++) g_signal_connect(prefs_widget(echo_toggles[i]), "toggled", G_CALLBACK(echo_sensitivity), NULL); + echo_sensitivity(NULL, NULL); + g_signal_connect(prefs_widget("enableSoundCheckButton"), "toggled", G_CALLBACK(sound_sensitivity), NULL); + sound_sensitivity(NULL, NULL); + g_signal_connect(prefs_widget("ocrBlackWhiteCheckButton"), "toggled", G_CALLBACK(ocr_sensitivity), NULL); + g_signal_connect(prefs_widget("ocrAutoRefreshCheckButton"), "toggled", G_CALLBACK(ocr_sensitivity), NULL); + ocr_sensitivity(NULL, NULL); + gtk_widget_show_all(prefs_widget("window")); + for (unsigned int i = 0; i < G_N_ELEMENTS(named); i++) { + GtkWidget *control = prefs_widget(named[i].id); + if (GTK_IS_COMBO_BOX_TEXT(control)) { + combo_changed(GTK_COMBO_BOX(control), (gpointer)named[i].name); + } + } + gtk_widget_grab_focus(prefs_widget("notebook")); + return 1; +} +void prefs_set_application(const char *name) { + if (!name || !name[0]) return; + // Hiding a notebook child leaves its tab in the AT-SPI tree. Remove the + // page entirely so application preferences expose no global controls. + GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("notebook")); + gtk_notebook_remove_page(notebook, gtk_notebook_page_num(notebook, prefs_widget("tentacles_page"))); + gchar *title = g_strdup_printf("Cthulhu Preferences for %s", name); + gtk_window_set_title(GTK_WINDOW(prefs_widget("window")), title); + g_free(title); +} +void prefs_iterate(void) { for (int i = 0; i < 64 && g_main_context_pending(NULL); i++) g_main_context_iteration(NULL, FALSE); } +int prefs_action(void) { int result = action; action = 0; return result; } +int prefs_voice_changed(void) { int result = voice_changed; voice_changed = 0; return result; } +void prefs_busy(int busy, int can_save) { + gtk_widget_set_sensitive(prefs_widget("notebook"), !busy); + gtk_widget_set_sensitive(prefs_widget("apply"), !busy); + gtk_widget_set_sensitive(prefs_widget("save"), !busy && can_save); + if (!busy && busy_focus) { + if (gtk_widget_get_sensitive(busy_focus)) gtk_widget_grab_focus(busy_focus); + busy_focus = NULL; + } +} +void prefs_present(void) { gtk_window_present(GTK_WINDOW(prefs_widget("window"))); } +void prefs_close(void) { busy_focus = NULL; gtk_widget_destroy(prefs_widget("window")); g_object_unref(builder); builder = NULL; } +void prefs_status(const char *message, int focus) { + gtk_entry_set_text(GTK_ENTRY(prefs_widget("status")), message); + atk_object_set_name(gtk_widget_get_accessible(prefs_widget("status")), message); + if (focus) gtk_widget_grab_focus(prefs_widget("status")); +} diff --git a/internal/preferences/native.h b/internal/preferences/native.h new file mode 100644 index 0000000..f701232 --- /dev/null +++ b/internal/preferences/native.h @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include +int prefs_init(const char *xml); +void prefs_set_application(const char *name); +void prefs_iterate(void); +int prefs_action(void); +int prefs_voice_changed(void); +void prefs_busy(int busy, int can_save); +void prefs_present(void); +void prefs_close(void); +void prefs_status(const char *message, int focus); +void prefs_number_set(const char *id, double value, int use_default); +void prefs_voice_defaults(int kind); +double prefs_number_value(const char *id); +int prefs_default(const char *id); +void prefs_choice_set(const char *id, int value); +int prefs_choice_get(const char *id); +void prefs_hardware_devices_clear(void); +void prefs_hardware_device_append(const char *text); +void prefs_hardware_device_set(const char *text); +const char *prefs_hardware_device_get(void); +void prefs_hardware_sensitivity(int enabled); +void prefs_combo_set(const char *id, int value); +int prefs_combo_get(const char *id); + +void prefs_check_set(const char *id, int value); +int prefs_check_get(const char *id); +void prefs_reading_set(const char *id, int value); +int prefs_reading_get(const char *id); +void prefs_range_set(const char *id, double value); +void prefs_spin_set(const char *id, double value); +double prefs_spin_get(const char *id); +void prefs_combo_clear(const char *id); +void prefs_combo_append(const char *id, const char *text); +void prefs_entry_set(const char *id, const char *text); +const char *prefs_entry_get(const char *id); + +void prefs_bindings_clear(void); +void prefs_binding_append(const char *label, const char *scope, const char *keys, const char *defaults); +int prefs_binding_selected(void); +void prefs_binding_select(int row); + +void prefs_modifier_set(const char *id, int enabled); +int prefs_modifier_get(const char *id); +void prefs_init_pronunciation(void); +void prefs_pronunciation_clear(void); +void prefs_pronunciation_append(const char *word, const char *replacement); +int prefs_pronunciation_count(void); +char *prefs_pronunciation_value(int row, int column); +void prefs_init_text_attributes(void); +void prefs_text_attributes_clear(void); +void prefs_text_attribute_append(const char *key, const char *label, int speak, int braille); +int prefs_text_attribute_count(void); +char *prefs_text_attribute_key(int row); +int prefs_text_attribute_enabled(int row, int column); + +void prefs_tentacles_clear(void); +void prefs_tentacle_append(int enabled, const char *name, const char *state, const char *version, const char *repository, const char *message, const char *shortcuts); +int prefs_tentacle_selected(void); +void prefs_tentacle_select(int row); +void prefs_tentacle_controls(int available, int installed, int enabled, int repository); +int prefs_tentacle_confirm(const char *message, const char *label); +void prefs_tentacle_repositories_clear(void); +void prefs_tentacle_repository_append(const char *label); +int prefs_tentacle_repository_selected(void); +void prefs_tentacle_repository_select(int page); diff --git a/internal/preferences/native_applications.c b/internal/preferences/native_applications.c new file mode 100644 index 0000000..6d0c4b2 --- /dev/null +++ b/internal/preferences/native_applications.c @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_applications.h" +#include + +// Used only in the isolated preferences helper. The reader's AT-SPI loop +// and timeout remain untouched. +AccessibleApplication *prefs_application_names(int *count) { + *count = 0; + if (atspi_init() != 0) return NULL; + atspi_set_timeout(250, 250); + AtspiAccessible *desktop = atspi_get_desktop(0); + if (!desktop) { atspi_exit(); return NULL; } + GError *error = NULL; + int children = MIN(atspi_accessible_get_child_count(desktop, &error), 4096); + g_clear_error(&error); + AccessibleApplication *names = g_new0(AccessibleApplication, MAX(children, 1)); + for (int i = 0; i < children; i++) { + AtspiAccessible *application = atspi_accessible_get_child_at_index(desktop, i, &error); + g_clear_error(&error); + if (!application) continue; + char *name = atspi_accessible_get_name(application, &error); + g_clear_error(&error); + unsigned int pid = atspi_accessible_get_process_id(application, &error); + g_clear_error(&error); + if ((name && *name) || pid) names[(*count)++] = (AccessibleApplication){name, pid}; + else g_free(name); + g_object_unref(application); + } + g_object_unref(desktop); + atspi_exit(); + return names; +} + +void prefs_free_application_names(AccessibleApplication *names, int count) { + for (int i = 0; i < count; i++) g_free(names[i].name); + g_free(names); +} + +static GtkWidget *chooser, *applicationTree; +static GtkListStore *applicationStore; +static int chooserAction; + +static void chooser_response(GtkDialog *dialog, gint response, gpointer data) { + (void)dialog; (void)data; + chooserAction = response == GTK_RESPONSE_OK ? 1 : 2; +} + +static void chooser_activate(GtkTreeView *view, GtkTreePath *path, GtkTreeViewColumn *column, gpointer data) { + (void)view; (void)path; (void)column; (void)data; + chooserAction = 1; +} + +static gboolean chooser_delete(GtkWidget *widget, GdkEvent *event, gpointer data) { + (void)widget; (void)event; (void)data; + chooserAction = 2; + return TRUE; +} + +static gboolean chooser_key(GtkWidget *widget, GdkEventKey *event, gpointer data) { + (void)widget; (void)data; + if (event->keyval != GDK_KEY_Escape) return FALSE; + chooserAction = 2; + return TRUE; +} + +int prefs_application_chooser_init(void) { + if (!gtk_init_check(NULL, NULL)) return 0; + chooserAction = 0; + chooser = gtk_dialog_new_with_buttons("Choose Application Preferences", NULL, 0, + "_Cancel", GTK_RESPONSE_CANCEL, "_Open Preferences", GTK_RESPONSE_OK, NULL); + gtk_window_set_default_size(GTK_WINDOW(chooser), 480, 420); + gtk_dialog_set_default_response(GTK_DIALOG(chooser), GTK_RESPONSE_OK); + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(chooser)); + GtkWidget *label = gtk_label_new_with_mnemonic("Choose an _application:"); + gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 6); + GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_widget_set_vexpand(scroll, TRUE); + gtk_box_pack_start(GTK_BOX(content), scroll, TRUE, TRUE, 6); + applicationStore = gtk_list_store_new(1, G_TYPE_STRING); + applicationTree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(applicationStore)); + gtk_tree_view_insert_column_with_attributes(GTK_TREE_VIEW(applicationTree), -1, + "Application", gtk_cell_renderer_text_new(), "text", 0, NULL); + gtk_tree_selection_set_mode(gtk_tree_view_get_selection(GTK_TREE_VIEW(applicationTree)), GTK_SELECTION_SINGLE); + gtk_label_set_mnemonic_widget(GTK_LABEL(label), applicationTree); + atk_object_set_name(gtk_widget_get_accessible(applicationTree), "Applications"); + atk_object_set_description(gtk_widget_get_accessible(applicationTree), + "Use arrow keys to choose a running application. Enter opens its preferences. Escape cancels. Tab leaves the list."); + gtk_container_add(GTK_CONTAINER(scroll), applicationTree); + g_signal_connect(chooser, "response", G_CALLBACK(chooser_response), NULL); + g_signal_connect(chooser, "delete-event", G_CALLBACK(chooser_delete), NULL); + g_signal_connect(chooser, "key-press-event", G_CALLBACK(chooser_key), NULL); + g_signal_connect(applicationTree, "row-activated", G_CALLBACK(chooser_activate), NULL); + return 1; +} + +void prefs_application_chooser_add(const char *name) { + GtkTreeIter iter; + gtk_list_store_append(applicationStore, &iter); + gtk_list_store_set(applicationStore, &iter, 0, name, -1); +} + +void prefs_application_chooser_present(void) { + gtk_widget_show_all(chooser); + gtk_window_present(GTK_WINDOW(chooser)); + GtkTreePath *path = NULL; + gtk_tree_view_get_cursor(GTK_TREE_VIEW(applicationTree), &path, NULL); + if (!path && gtk_tree_model_iter_n_children(GTK_TREE_MODEL(applicationStore), NULL)) { + path = gtk_tree_path_new_first(); + gtk_tree_view_set_cursor(GTK_TREE_VIEW(applicationTree), path, NULL, FALSE); + } + if (path) gtk_tree_path_free(path); + gtk_widget_grab_focus(applicationTree); +} + +void prefs_application_chooser_iterate(void) { + while (gtk_events_pending()) gtk_main_iteration_do(FALSE); +} +int prefs_application_chooser_action(void) { return chooserAction; } +char *prefs_application_chooser_selected(void) { + GtkTreeIter iter; + if (!gtk_tree_selection_get_selected(gtk_tree_view_get_selection(GTK_TREE_VIEW(applicationTree)), NULL, &iter)) { + chooserAction = 0; + return NULL; + } + char *name = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(applicationStore), &iter, 0, &name, -1); + return name; +} +void prefs_application_chooser_close(void) { + gtk_widget_destroy(chooser); + g_object_unref(applicationStore); + chooser = applicationTree = NULL; + applicationStore = NULL; +} diff --git a/internal/preferences/native_applications.h b/internal/preferences/native_applications.h new file mode 100644 index 0000000..72b195a --- /dev/null +++ b/internal/preferences/native_applications.h @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include +typedef struct { char *name; unsigned int pid; } AccessibleApplication; +AccessibleApplication *prefs_application_names(int *count); +void prefs_free_application_names(AccessibleApplication *names, int count); +int prefs_application_chooser_init(void); +void prefs_application_chooser_add(const char *name); +void prefs_application_chooser_present(void); +void prefs_application_chooser_iterate(void); +int prefs_application_chooser_action(void); +char *prefs_application_chooser_selected(void); +void prefs_application_chooser_close(void); diff --git a/internal/preferences/native_bindings.c b/internal/preferences/native_bindings.c new file mode 100644 index 0000000..488a362 --- /dev/null +++ b/internal/preferences/native_bindings.c @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +static GtkListStore *binding_store; +static void binding_selected(GtkTreeSelection *selection, gpointer data) { + (void)data; + GtkTreeModel *model; + GtkTreeIter iter; + gboolean selected = gtk_tree_selection_get_selected(selection, &model, &iter); + const char *controls[] = {"keyBindingEntry", "keyBindingSet", "keyBindingClear", "keyBindingReset"}; + for (unsigned i = 0; i < G_N_ELEMENTS(controls); i++) gtk_widget_set_sensitive(prefs_widget(controls[i]), selected); + gchar *keys = NULL; + if (selected) gtk_tree_model_get(model, &iter, 2, &keys, -1); + gtk_entry_set_text(GTK_ENTRY(prefs_widget("keyBindingEntry")), keys && strcmp(keys, "Unbound") != 0 ? keys : ""); + g_free(keys); +} +static void binding_clicked(GtkButton *button, gpointer data) { (void)button; prefs_set_action(GPOINTER_TO_INT(data)); } +void prefs_init_key_bindings(void) { + binding_store = gtk_list_store_new(4, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING); + GtkTreeView *tree = GTK_TREE_VIEW(prefs_widget("keyBindingsTreeview")); + gtk_tree_view_set_model(tree, GTK_TREE_MODEL(binding_store)); + const char *titles[] = {"Command", "Scope", "Binding", "Default binding"}; + for (int i = 0; i < 4; i++) { + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + gtk_tree_view_append_column(tree, gtk_tree_view_column_new_with_attributes(titles[i], renderer, "text", i, NULL)); + } + gtk_tree_selection_set_mode(gtk_tree_view_get_selection(tree), GTK_SELECTION_SINGLE); + g_signal_connect(gtk_tree_view_get_selection(tree), "changed", G_CALLBACK(binding_selected), NULL); + const char *buttons[] = {"keyBindingSet", "keyBindingClear", "keyBindingReset", "keyBindingResetAll"}; + for (int i = 0; i < 4; i++) g_signal_connect(prefs_widget(buttons[i]), "clicked", G_CALLBACK(binding_clicked), GINT_TO_POINTER(i + 4)); + atk_object_set_name(gtk_widget_get_accessible(GTK_WIDGET(tree)), "Key bindings"); + binding_selected(gtk_tree_view_get_selection(tree), NULL); + g_object_unref(binding_store); // The tree owns the model. +} +void prefs_bindings_clear(void) { gtk_list_store_clear(binding_store); } +void prefs_binding_append(const char *label, const char *scope, const char *keys, const char *defaults) { + GtkTreeIter iter; + gtk_list_store_append(binding_store, &iter); + gtk_list_store_set(binding_store, &iter, 0, label, 1, scope, 2, keys, 3, defaults, -1); +} +int prefs_binding_selected(void) { + GtkTreeModel *model; + GtkTreeIter iter; + if (!gtk_tree_selection_get_selected(gtk_tree_view_get_selection(GTK_TREE_VIEW(prefs_widget("keyBindingsTreeview"))), &model, &iter)) return -1; + GtkTreePath *path = gtk_tree_model_get_path(model, &iter); + int row = gtk_tree_path_get_indices(path)[0]; + gtk_tree_path_free(path); + return row; +} +void prefs_binding_select(int row) { + if (row < 0) return; + GtkTreePath *path = gtk_tree_path_new_from_indices(row, -1); + // Setting the cursor grabs focus and can switch the notebook to this page + // while loading preferences or changing the layout combo. Select the row + // without moving keyboard focus out of the current control. + GtkTreeView *tree = GTK_TREE_VIEW(prefs_widget("keyBindingsTreeview")); + gtk_tree_selection_select_path(gtk_tree_view_get_selection(tree), path); + gtk_tree_view_scroll_to_cell(tree, path, NULL, FALSE, 0, 0); + gtk_tree_path_free(path); +} +static GtkListStore *modifierStore; +static const struct { const char *id; const char *label; } modifierRows[] = { + {"Insert", "Insert"}, {"KP_Insert", "Numpad Insert"}, {"Caps_Lock", "Caps Lock"}, +}; +static void name_modifier_cells(void) { + AtkObject *accessible = gtk_widget_get_accessible(prefs_widget("cthulhuModifiersTreeview")); + if (!ATK_IS_TABLE(accessible)) return; + for (unsigned i = 0; i < G_N_ELEMENTS(modifierRows); i++) { + AtkObject *cell = atk_table_ref_at(ATK_TABLE(accessible), i, 0); + if (!cell) continue; + gboolean enabled = prefs_modifier_get(modifierRows[i].id); + const char *name = enabled ? "checked" : "unchecked"; + if (g_strcmp0(atk_object_get_name(cell), name) != 0) atk_object_set_name(cell, name); + g_object_unref(cell); + } +} +void prefs_modifier_set(const char *id, int enabled) { + for (unsigned i = 0; i < G_N_ELEMENTS(modifierRows); i++) { + if (strcmp(id, modifierRows[i].id) != 0) continue; + if (prefs_modifier_get(id) == (enabled != 0)) return; + GtkTreeIter iter; + if (gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(modifierStore), &iter, NULL, i)) + gtk_list_store_set(modifierStore, &iter, 1, enabled != 0, -1); + name_modifier_cells(); + return; + } +} +int prefs_modifier_get(const char *id) { + for (unsigned i = 0; i < G_N_ELEMENTS(modifierRows); i++) { + if (strcmp(id, modifierRows[i].id) != 0) continue; + GtkTreeIter iter; + gboolean enabled = FALSE; + if (gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(modifierStore), &iter, NULL, i)) + gtk_tree_model_get(GTK_TREE_MODEL(modifierStore), &iter, 1, &enabled, -1); + return enabled; + } + return FALSE; +} +static void toggle_modifier_path(const char *pathString) { + GtkTreeIter iter; + if (!gtk_tree_model_get_iter_from_string(GTK_TREE_MODEL(modifierStore), &iter, pathString)) return; + gboolean enabled; + gtk_tree_model_get(GTK_TREE_MODEL(modifierStore), &iter, 1, &enabled, -1); + gtk_list_store_set(modifierStore, &iter, 1, !enabled, -1); + name_modifier_cells(); +} +static void modifier_toggled(GtkCellRendererToggle *renderer, gchar *pathString, gpointer data) { + (void)renderer; (void)data; + toggle_modifier_path(pathString); +} +static gboolean modifier_key_press(GtkWidget *view, GdkEventKey *event, gpointer data) { + (void)data; + if (event->keyval != GDK_KEY_space && event->keyval != GDK_KEY_KP_Space) return FALSE; + GtkTreePath *path = NULL; + gtk_tree_view_get_cursor(GTK_TREE_VIEW(view), &path, NULL); + if (!path) return FALSE; + gchar *pathString = gtk_tree_path_to_string(path); + toggle_modifier_path(pathString); + g_free(pathString); + gtk_tree_path_free(path); + return TRUE; +} +static void layout_modifiers(GtkToggleButton *button, gpointer data) { + if (!gtk_toggle_button_get_active(button)) return; + int laptop = GPOINTER_TO_INT(data); + prefs_modifier_set("Insert", FALSE); + prefs_modifier_set("KP_Insert", !laptop); + prefs_modifier_set("Caps_Lock", laptop); +} +void prefs_init_modifiers(void) { + modifierStore = gtk_list_store_new(2, G_TYPE_STRING, G_TYPE_BOOLEAN); + for (unsigned i = 0; i < G_N_ELEMENTS(modifierRows); i++) { + GtkTreeIter iter; + gtk_list_store_append(modifierStore, &iter); + gtk_list_store_set(modifierStore, &iter, 0, modifierRows[i].label, 1, FALSE, -1); + } + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("cthulhuModifiersTreeview")); + gtk_tree_view_set_model(view, GTK_TREE_MODEL(modifierStore)); + GtkCellRenderer *textRenderer = gtk_cell_renderer_text_new(); + GtkCellRenderer *toggleRenderer = gtk_cell_renderer_toggle_new(); + g_signal_connect(toggleRenderer, "toggled", G_CALLBACK(modifier_toggled), NULL); + gtk_tree_view_append_column(view, gtk_tree_view_column_new_with_attributes("Enabled", toggleRenderer, "active", 1, NULL)); + gtk_tree_view_append_column(view, gtk_tree_view_column_new_with_attributes("Cthulhu key", textRenderer, "text", 0, NULL)); + g_signal_connect(view, "key-press-event", G_CALLBACK(modifier_key_press), NULL); + AtkObject *accessible = gtk_widget_get_accessible(GTK_WIDGET(view)); + atk_object_set_name(accessible, "Cthulhu modifier keys"); + atk_object_set_description(accessible, "Use arrow keys to select Insert, Numpad Insert, or Caps Lock. Space toggles each key independently. Tab leaves the table."); + g_signal_connect(prefs_widget("generalDesktopButton"), "toggled", G_CALLBACK(layout_modifiers), GINT_TO_POINTER(0)); + g_signal_connect(prefs_widget("generalLaptopButton"), "toggled", G_CALLBACK(layout_modifiers), GINT_TO_POINTER(1)); + name_modifier_cells(); + g_object_unref(modifierStore); // The tree owns the model. +} + diff --git a/internal/preferences/native_internal.h b/internal/preferences/native_internal.h new file mode 100644 index 0000000..db43e07 --- /dev/null +++ b/internal/preferences/native_internal.h @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef CTHULHU_PREFERENCES_NATIVE_INTERNAL_H +#define CTHULHU_PREFERENCES_NATIVE_INTERNAL_H +#include "native.h" + +// Private C boundary. The window owner retains the builder and action slot; +// widget models and signal callbacks stay with their respective tables. +GtkWidget *prefs_widget(const char *id); +void prefs_set_action(int value); +void prefs_init_key_bindings(void); +void prefs_init_modifiers(void); +void prefs_init_reading_settings(void); +void prefs_init_pronunciation(void); +void prefs_init_text_attributes(void); +void prefs_init_tentacles(void); +#endif diff --git a/internal/preferences/native_reading.c b/internal/preferences/native_reading.c new file mode 100644 index 0000000..7091242 --- /dev/null +++ b/internal/preferences/native_reading.c @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +static GtkListStore *reading_store; +static const struct { const char *id; const char *label; } reading_rows[] = { + {"only_speak_displayed_text", "Only speak displayed text"}, + {"automatically_read_title_changes", "Automatically read title changes"}, + {"automatically_read_clipboard_changes", "Automatically read clipboard changes"}, + {"enable_pause_breaks", "Break speech into chunks between pauses"}, + {"speak_numbers_as_digits", "Speak numbers as digits"}, + {"speak_multicase_strings_as_words", "Speak multicase strings as words"}, + {"enable_indentation", "Enable indentation presentation"}, + {"speak_blank_lines", "Speak blank lines"}, + {"enable_mnemonic_speaking", "Speak object mnemonics"}, + {"enable_position_speaking", "Speak child position"}, + {"enable_tutorial_messages", "Speak tutorial messages"}, + {"speak_description", "Speak object descriptions"}, + {"messages_are_detailed", "System messages are detailed"}, + {"use_color_names", "Speak colors as names"}, + {"speak_misspelled_indicator", "Speak misspelled-word indicator"}, + {"speak_context_blockquote", "Announce block quotes during navigation"}, + {"speak_context_list", "Announce lists during navigation"}, + {"speak_context_table", "Announce tables during navigation"}, + {"speak_context_panel", "Announce panels during navigation"}, + {"speak_context_landmark", "Announce landmarks during navigation"}, + {"speak_context_non_landmark_form", "Announce forms during navigation"}, + {"read_full_row_in_gui_table", "Speak full row in GUI tables"}, + {"read_full_row_in_document_table", "Speak full row in document tables"}, + {"read_full_row_in_spreadsheet", "Speak full row in spreadsheets"}, + {"present_tooltips", "Present tooltips"}, + {"enable_mouse_review", "Speak object under mouse"}, + {"say_all_context_blockquote", "Announce block quotes in Say All"}, + {"say_all_context_list", "Announce lists in Say All"}, + {"say_all_context_table", "Announce tables in Say All"}, + {"say_all_context_panel", "Announce panels in Say All"}, + {"say_all_context_non_landmark_form", "Announce forms in Say All"}, + {"say_all_context_landmark", "Announce landmarks in Say All"}, +}; + +enum { + READING_ID, + READING_LABEL, + READING_ENABLED, + READING_N_COLUMNS +}; +static void name_reading_toggle_cells(void); + +static void reading_toggled(GtkCellRendererToggle *renderer, gchar *path_string, gpointer data) { + (void)renderer; (void)data; + GtkTreeIter iter; + if (!gtk_tree_model_get_iter_from_string(GTK_TREE_MODEL(reading_store), &iter, path_string)) { + return; + } + gboolean active; + gtk_tree_model_get(GTK_TREE_MODEL(reading_store), &iter, READING_ENABLED, &active, -1); + gtk_list_store_set(reading_store, &iter, READING_ENABLED, !active, -1); + name_reading_toggle_cells(); +} + +static gboolean reading_key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) { + (void)data; + if (event->keyval != GDK_KEY_space && event->keyval != GDK_KEY_KP_Space) return FALSE; + GtkTreePath *path = NULL; + gtk_tree_view_get_cursor(GTK_TREE_VIEW(widget), &path, NULL); + if (!path) return FALSE; + GtkTreeIter iter; + gboolean found = gtk_tree_model_get_iter(GTK_TREE_MODEL(reading_store), &iter, path); + gtk_tree_path_free(path); + if (!found) return FALSE; + gboolean active; + gtk_tree_model_get(GTK_TREE_MODEL(reading_store), &iter, READING_ENABLED, &active, -1); + gtk_list_store_set(reading_store, &iter, READING_ENABLED, !active, -1); + name_reading_toggle_cells(); + return TRUE; +} + +static void name_reading_toggle_cells(void) { + AtkObject *accessible = gtk_widget_get_accessible(prefs_widget("readingSettingsTreeview")); + if (!ATK_IS_TABLE(accessible)) return; + for (unsigned int i = 0; i < G_N_ELEMENTS(reading_rows); i++) { + AtkObject *cell = atk_table_ref_at(ATK_TABLE(accessible), i, 0); + if (!cell) continue; + GtkTreeIter iter; + gboolean active = FALSE; + if (gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(reading_store), &iter, NULL, i)) + gtk_tree_model_get(GTK_TREE_MODEL(reading_store), &iter, READING_ENABLED, &active, -1); + const char *name = active ? "checked" : "unchecked"; + if (g_strcmp0(atk_object_get_name(cell), name) != 0) atk_object_set_name(cell, name); + g_object_unref(cell); + } +} + +void prefs_init_reading_settings(void) { + reading_store = gtk_list_store_new(READING_N_COLUMNS, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN); + for (unsigned int i = 0; i < G_N_ELEMENTS(reading_rows); i++) { + GtkTreeIter iter; + gtk_list_store_append(reading_store, &iter); + gtk_list_store_set(reading_store, &iter, + READING_ID, reading_rows[i].id, + READING_LABEL, reading_rows[i].label, + READING_ENABLED, FALSE, + -1); + } + + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("readingSettingsTreeview")); + gtk_tree_view_set_model(view, GTK_TREE_MODEL(reading_store)); + gtk_tree_view_set_headers_visible(view, TRUE); + GtkCellRenderer *text_renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn *setting_column = gtk_tree_view_column_new_with_attributes( + "Setting", text_renderer, "text", READING_LABEL, NULL); + GtkCellRenderer *toggle_renderer = gtk_cell_renderer_toggle_new(); + g_object_set(toggle_renderer, "activatable", TRUE, NULL); + g_signal_connect(toggle_renderer, "toggled", G_CALLBACK(reading_toggled), NULL); + GtkTreeViewColumn *enabled_column = gtk_tree_view_column_new_with_attributes( + "Enabled", toggle_renderer, "active", READING_ENABLED, NULL); + gtk_tree_view_append_column(view, enabled_column); + gtk_tree_view_append_column(view, setting_column); + g_signal_connect(view, "key-press-event", G_CALLBACK(reading_key_press), NULL); + name_reading_toggle_cells(); +} + +void prefs_reading_set(const char *id, int value) { + GtkTreeIter iter; + gboolean valid = gtk_tree_model_get_iter_first(GTK_TREE_MODEL(reading_store), &iter); + while (valid) { + gchar *row_id; + gboolean active; + gtk_tree_model_get(GTK_TREE_MODEL(reading_store), &iter, + READING_ID, &row_id, READING_ENABLED, &active, -1); + if (g_strcmp0(row_id, id) == 0) { + if (active != (value != 0)) { + gtk_list_store_set(reading_store, &iter, READING_ENABLED, value != 0, -1); + name_reading_toggle_cells(); + } + g_free(row_id); + return; + } + g_free(row_id); + valid = gtk_tree_model_iter_next(GTK_TREE_MODEL(reading_store), &iter); + } +} + +int prefs_reading_get(const char *id) { + GtkTreeIter iter; + gboolean valid = gtk_tree_model_get_iter_first(GTK_TREE_MODEL(reading_store), &iter); + while (valid) { + gchar *row_id; + gtk_tree_model_get(GTK_TREE_MODEL(reading_store), &iter, READING_ID, &row_id, -1); + if (g_strcmp0(row_id, id) == 0) { + gboolean active; + gtk_tree_model_get(GTK_TREE_MODEL(reading_store), &iter, READING_ENABLED, &active, -1); + g_free(row_id); + return active ? 1 : 0; + } + g_free(row_id); + valid = gtk_tree_model_iter_next(GTK_TREE_MODEL(reading_store), &iter); + } + return 0; +} + diff --git a/internal/preferences/native_tentacles.c b/internal/preferences/native_tentacles.c new file mode 100644 index 0000000..9cc3df8 --- /dev/null +++ b/internal/preferences/native_tentacles.c @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +static GtkListStore *tentacleStore; +static gboolean updating; + +static void name_toggle_cells(void) { + AtkObject *accessible = gtk_widget_get_accessible(prefs_widget("tentaclesTree")); + if (!ATK_IS_TABLE(accessible)) return; + int rows = gtk_tree_model_iter_n_children(GTK_TREE_MODEL(tentacleStore), NULL); + for (int row = 0; row < rows; row++) { + AtkObject *cell = atk_table_ref_at(ATK_TABLE(accessible), row, 0); + if (!cell) continue; + GtkTreeIter iter; + gboolean enabled = FALSE; + gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(tentacleStore), &iter, NULL, row); + gtk_tree_model_get(GTK_TREE_MODEL(tentacleStore), &iter, 0, &enabled, -1); + atk_object_set_name(cell, enabled ? "checked" : "unchecked"); + g_object_unref(cell); + } +} +static void tentacle_selected(GtkTreeSelection *selection, gpointer data) { + (void)selection; (void)data; + if (!updating) prefs_set_action(16); +} +static void tentacle_clicked(GtkButton *button, gpointer data) { + (void)button; + prefs_set_action(GPOINTER_TO_INT(data)); +} +static void tentacle_toggled(GtkCellRendererToggle *renderer, gchar *pathString, gpointer data) { + (void)renderer; (void)data; + if (updating) return; + GtkTreePath *path = gtk_tree_path_new_from_string(pathString); + gtk_tree_view_set_cursor(GTK_TREE_VIEW(prefs_widget("tentaclesTree")), path, NULL, FALSE); + 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); +} +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); + return TRUE; +} +static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint index, gpointer data) { + (void)notebook; (void)index; (void)data; + GtkWidget *scroll = prefs_widget("tentacleTableScroll"); + GtkWidget *parent = gtk_widget_get_parent(scroll); + if (parent != page) { + g_object_ref(scroll); + gtk_container_remove(GTK_CONTAINER(parent), scroll); + gtk_container_add(GTK_CONTAINER(page), scroll); + g_object_unref(scroll); + } + if (!updating) prefs_set_action(17); +} +void prefs_init_tentacles(void) { + updating = FALSE; + tentacleStore = gtk_list_store_new(7, G_TYPE_BOOLEAN, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING); + GtkTreeView *tree = GTK_TREE_VIEW(prefs_widget("tentaclesTree")); + gtk_tree_view_set_model(tree, GTK_TREE_MODEL(tentacleStore)); + GtkCellRenderer *toggle = gtk_cell_renderer_toggle_new(); + g_signal_connect(toggle, "toggled", G_CALLBACK(tentacle_toggled), NULL); + gtk_tree_view_append_column(tree, gtk_tree_view_column_new_with_attributes("Enabled", toggle, "active", 0, NULL)); + const char *titles[] = {"Name", "Status", "Version", "Repository", "Details", "Shortcuts"}; + for (unsigned i = 0; i < G_N_ELEMENTS(titles); i++) { + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + gtk_tree_view_append_column(tree, gtk_tree_view_column_new_with_attributes(titles[i], renderer, "text", i + 1, NULL)); + } + gtk_tree_selection_set_mode(gtk_tree_view_get_selection(tree), GTK_SELECTION_SINGLE); + g_signal_connect(gtk_tree_view_get_selection(tree), "changed", G_CALLBACK(tentacle_selected), NULL); + 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}, {"tentacleSettings", 20}, + }; + for (unsigned i = 0; i < G_N_ELEMENTS(buttons); i++) + g_signal_connect(prefs_widget(buttons[i].id), "clicked", G_CALLBACK(tentacle_clicked), GINT_TO_POINTER(buttons[i].action)); + atk_object_set_name(gtk_widget_get_accessible(GTK_WIDGET(tree)), "Tentacles"); + atk_object_set_name(gtk_widget_get_accessible(prefs_widget("tentacleRepositoryURL")), "Repository URL"); + atk_object_set_name(gtk_widget_get_accessible(prefs_widget("tentacleRepositories")), "Tentacle repositories"); + g_object_unref(tentacleStore); // Owned by the tree. +} +void prefs_tentacles_clear(void) { + updating = TRUE; + gtk_list_store_clear(tentacleStore); +} +void prefs_tentacle_append(int enabled, const char *name, const char *state, const char *version, const char *repository, const char *message, const char *shortcuts) { + GtkTreeIter iter; + gtk_list_store_append(tentacleStore, &iter); + gtk_list_store_set(tentacleStore, &iter, 0, enabled != 0, 1, name, 2, state, 3, version, 4, repository, 5, message, 6, shortcuts, -1); +} +int prefs_tentacle_selected(void) { + GtkTreeModel *model; + GtkTreeIter iter; + GtkTreeView *tree = GTK_TREE_VIEW(prefs_widget("tentaclesTree")); + if (!gtk_tree_selection_get_selected(gtk_tree_view_get_selection(tree), &model, &iter)) return -1; + GtkTreePath *path = gtk_tree_model_get_path(model, &iter); + int row = gtk_tree_path_get_indices(path)[0]; + gtk_tree_path_free(path); + return row; +} +void prefs_tentacle_select(int row) { + updating = TRUE; + GtkTreeView *tree = GTK_TREE_VIEW(prefs_widget("tentaclesTree")); + gtk_tree_selection_unselect_all(gtk_tree_view_get_selection(tree)); + if (row >= 0) { + GtkTreePath *path = gtk_tree_path_new_from_indices(row, -1); + if (gtk_widget_has_focus(GTK_WIDGET(tree))) gtk_tree_view_set_cursor(tree, path, NULL, FALSE); + else gtk_tree_selection_select_path(gtk_tree_view_get_selection(tree), path); + gtk_tree_path_free(path); + } + name_toggle_cells(); + updating = FALSE; +} +void prefs_tentacle_controls(int available, int installed, int enabled, int repository) { + gtk_widget_set_sensitive(prefs_widget("tentacleNew"), available); + gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryURL"), available); + gtk_widget_set_sensitive(prefs_widget("tentacleRetry"), available && installed && enabled); + gtk_widget_set_sensitive(prefs_widget("tentacleSettings"), available && installed); + gtk_widget_set_sensitive(prefs_widget("tentacleRemove"), available && installed); + gtk_widget_set_sensitive(prefs_widget("tentacleInstall"), available && !installed && prefs_tentacle_selected() >= 0); + gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRefresh"), available && repository); + gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRemove"), available && repository); +} +int prefs_tentacle_confirm(const char *message, const char *label) { + GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(prefs_widget("window")), + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, GTK_MESSAGE_QUESTION, + GTK_BUTTONS_NONE, "%s", message); + gtk_window_set_title(GTK_WINDOW(dialog), "Install tentacle"); + gtk_dialog_add_button(GTK_DIALOG(dialog), "Cancel", GTK_RESPONSE_CANCEL); + gtk_dialog_add_button(GTK_DIALOG(dialog), label, GTK_RESPONSE_ACCEPT); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL); + int response = gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + return response == GTK_RESPONSE_ACCEPT; +} +void prefs_tentacle_repositories_clear(void) { + updating = TRUE; + GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("tentacleRepositories")); + gtk_notebook_set_current_page(notebook, 0); + while (gtk_notebook_get_n_pages(notebook) > 1) gtk_notebook_remove_page(notebook, -1); + updating = FALSE; +} +void prefs_tentacle_repository_append(const char *label) { + updating = TRUE; + GtkWidget *page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_notebook_append_page(GTK_NOTEBOOK(prefs_widget("tentacleRepositories")), page, gtk_label_new(label)); + gtk_widget_show_all(page); + updating = FALSE; +} +int prefs_tentacle_repository_selected(void) { + return gtk_notebook_get_current_page(GTK_NOTEBOOK(prefs_widget("tentacleRepositories"))); +} +void prefs_tentacle_repository_select(int page) { + updating = TRUE; + gtk_notebook_set_current_page(GTK_NOTEBOOK(prefs_widget("tentacleRepositories")), page); + updating = FALSE; +} diff --git a/internal/preferences/native_text_attributes.c b/internal/preferences/native_text_attributes.c new file mode 100644 index 0000000..ab78534 --- /dev/null +++ b/internal/preferences/native_text_attributes.c @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +enum { WORD, REPLACEMENT }; +static GtkListStore *pronunciations; + +static void pronunciation_edited(GtkCellRendererText *renderer, gchar *path, gchar *text, gpointer data) { + (void)renderer; + GtkTreeIter iter; + if (gtk_tree_model_get_iter_from_string(GTK_TREE_MODEL(pronunciations), &iter, path)) + gtk_list_store_set(pronunciations, &iter, GPOINTER_TO_INT(data), text, -1); +} + +static void pronunciation_add(GtkButton *button, gpointer data) { + (void)button; (void)data; + GtkTreeIter iter; + gtk_list_store_append(pronunciations, &iter); + gtk_list_store_set(pronunciations, &iter, WORD, "", REPLACEMENT, "", -1); + GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(pronunciations), &iter); + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("pronunciationTreeView")); + gtk_widget_grab_focus(GTK_WIDGET(view)); + gtk_tree_view_set_cursor(view, path, gtk_tree_view_get_column(view, WORD), TRUE); + gtk_tree_path_free(path); +} + +static void pronunciation_delete(GtkButton *button, gpointer data) { + (void)button; (void)data; + GtkTreeIter iter; + GtkTreeSelection *selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(prefs_widget("pronunciationTreeView"))); + if (gtk_tree_selection_get_selected(selection, NULL, &iter)) gtk_list_store_remove(pronunciations, &iter); + gtk_widget_grab_focus(prefs_widget("pronunciationTreeView")); +} + +static gboolean pronunciation_key(GtkWidget *widget, GdkEventKey *event, gpointer data) { + (void)data; + if (event->keyval != GDK_KEY_Return && event->keyval != GDK_KEY_KP_Enter && event->keyval != GDK_KEY_F2) return FALSE; + GtkTreePath *path = NULL; + GtkTreeViewColumn *column = NULL; + gtk_tree_view_get_cursor(GTK_TREE_VIEW(widget), &path, &column); + if (!path) return FALSE; + gtk_tree_view_set_cursor(GTK_TREE_VIEW(widget), path, column ? column : gtk_tree_view_get_column(GTK_TREE_VIEW(widget), 0), TRUE); + gtk_tree_path_free(path); + return TRUE; +} + +void prefs_init_pronunciation(void) { + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("pronunciationTreeView")); + pronunciations = gtk_list_store_new(2, G_TYPE_STRING, G_TYPE_STRING); + gtk_tree_view_set_model(view, GTK_TREE_MODEL(pronunciations)); + const char *labels[] = {"Word", "Pronunciation"}; + for (int column = 0; column < 2; column++) { + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + g_object_set(renderer, "editable", TRUE, NULL); + g_signal_connect(renderer, "edited", G_CALLBACK(pronunciation_edited), GINT_TO_POINTER(column)); + gtk_tree_view_append_column(view, gtk_tree_view_column_new_with_attributes(labels[column], renderer, "text", column, NULL)); + } + atk_object_set_name(gtk_widget_get_accessible(GTK_WIDGET(view)), "Pronunciation dictionary"); + atk_object_set_description(gtk_widget_get_accessible(GTK_WIDGET(view)), "Select a cell and press Enter to edit it. Tab moves to the next control."); + g_signal_connect(view, "key-press-event", G_CALLBACK(pronunciation_key), NULL); + g_signal_connect(prefs_widget("pronunciationAddButton"), "clicked", G_CALLBACK(pronunciation_add), NULL); + g_signal_connect(prefs_widget("pronunciationDeleteButton"), "clicked", G_CALLBACK(pronunciation_delete), NULL); + g_object_unref(pronunciations); +} + +void prefs_pronunciation_clear(void) { gtk_list_store_clear(pronunciations); } +void prefs_pronunciation_append(const char *word, const char *replacement) { + GtkTreeIter iter; + gtk_list_store_append(pronunciations, &iter); + gtk_list_store_set(pronunciations, &iter, WORD, word, REPLACEMENT, replacement, -1); +} +int prefs_pronunciation_count(void) { return gtk_tree_model_iter_n_children(GTK_TREE_MODEL(pronunciations), NULL); } +char *prefs_pronunciation_value(int row, int column) { + GtkTreeIter iter; + if (column < 0 || column > 1 || !gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(pronunciations), &iter, NULL, row)) return NULL; + char *value = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(pronunciations), &iter, column, &value, -1); + return value; +} + +enum { ATTRIBUTE_KEY, ATTRIBUTE_LABEL, ATTRIBUTE_SPEAK, ATTRIBUTE_BRAILLE }; +static GtkListStore *attributes; + +static void attribute_toggle(GtkCellRendererToggle *renderer, gchar *path, gpointer data) { + (void)renderer; + GtkTreeIter iter; + int column = GPOINTER_TO_INT(data); + if (!gtk_tree_model_get_iter_from_string(GTK_TREE_MODEL(attributes), &iter, path)) return; + gboolean active = FALSE; + gtk_tree_model_get(GTK_TREE_MODEL(attributes), &iter, column, &active, -1); + gtk_list_store_set(attributes, &iter, column, !active, -1); +} + +static gboolean attribute_key(GtkWidget *widget, GdkEventKey *event, gpointer data) { + (void)data; + if (event->keyval != GDK_KEY_space && event->keyval != GDK_KEY_KP_Space) return FALSE; + GtkTreePath *path = NULL; + GtkTreeViewColumn *column = NULL; + gtk_tree_view_get_cursor(GTK_TREE_VIEW(widget), &path, &column); + if (!path) return FALSE; + GtkTreeIter iter; + gboolean found = gtk_tree_model_get_iter(GTK_TREE_MODEL(attributes), &iter, path); + gtk_tree_path_free(path); + if (!found) return FALSE; + int selected = column == gtk_tree_view_get_column(GTK_TREE_VIEW(widget), 2) ? ATTRIBUTE_BRAILLE : ATTRIBUTE_SPEAK; + gboolean active = FALSE; + gtk_tree_model_get(GTK_TREE_MODEL(attributes), &iter, selected, &active, -1); + gtk_list_store_set(attributes, &iter, selected, !active, -1); + return TRUE; +} + +static void attribute_move(GtkButton *button, gpointer data) { + (void)button; + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("textAttributesTreeView")); + GtkTreeIter iter, other; + if (!gtk_tree_selection_get_selected(gtk_tree_view_get_selection(view), NULL, &iter)) return; + GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(attributes), &iter); + int row = gtk_tree_path_get_indices(path)[0]; + int last = gtk_tree_model_iter_n_children(GTK_TREE_MODEL(attributes), NULL) - 1; + int target = row; + switch (GPOINTER_TO_INT(data)) { + case 0: target = 0; break; + case 1: target = row - 1; break; + case 2: target = row + 1; break; + case 3: target = last; break; + } + if (target >= 0 && target <= last && target != row && + gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(attributes), &other, NULL, target)) { + if (target < row) gtk_list_store_move_before(attributes, &iter, &other); + else gtk_list_store_move_after(attributes, &iter, &other); + GtkTreePath *new_path = gtk_tree_path_new_from_indices(target, -1); + gtk_tree_view_set_cursor(view, new_path, NULL, FALSE); + gtk_tree_path_free(new_path); + } + gtk_tree_path_free(path); + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +static void attribute_select_all(GtkButton *button, gpointer data) { + (void)button; + gboolean active = GPOINTER_TO_INT(data) != 0; + GtkTreeIter iter; + gboolean valid = gtk_tree_model_get_iter_first(GTK_TREE_MODEL(attributes), &iter); + while (valid) { + gtk_list_store_set(attributes, &iter, ATTRIBUTE_SPEAK, active, ATTRIBUTE_BRAILLE, active, -1); + valid = gtk_tree_model_iter_next(GTK_TREE_MODEL(attributes), &iter); + } +} + +static void attribute_reset(GtkButton *button, gpointer data) { (void)button; (void)data; prefs_set_action(8); } + +void prefs_init_text_attributes(void) { + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("textAttributesTreeView")); + attributes = gtk_list_store_new(4, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN, G_TYPE_BOOLEAN); + gtk_tree_view_set_model(view, GTK_TREE_MODEL(attributes)); + GtkCellRenderer *text = gtk_cell_renderer_text_new(); + gtk_tree_view_append_column(view, gtk_tree_view_column_new_with_attributes("Attribute", text, "text", ATTRIBUTE_LABEL, NULL)); + const char *labels[] = {"Speak", "Braille"}; + for (int i = 0; i < 2; i++) { + GtkCellRenderer *renderer = gtk_cell_renderer_toggle_new(); + g_object_set(renderer, "activatable", TRUE, NULL); + g_signal_connect(renderer, "toggled", G_CALLBACK(attribute_toggle), GINT_TO_POINTER(i+ATTRIBUTE_SPEAK)); + gtk_tree_view_append_column(view, gtk_tree_view_column_new_with_attributes(labels[i], renderer, "active", i+ATTRIBUTE_SPEAK, NULL)); + } + g_signal_connect(view, "key-press-event", G_CALLBACK(attribute_key), NULL); + AtkObject *accessible = gtk_widget_get_accessible(GTK_WIDGET(view)); + atk_object_set_name(accessible, "Text attributes"); + atk_object_set_description(accessible, "Use arrows to select an attribute. Space toggles the selected Speak or Braille column. Use the Move buttons to change order."); + const char *moves[] = {"textMoveToTopButton", "textMoveUpOneButton", "textMoveDownOneButton", "textMoveToBottomButton"}; + for (int i = 0; i < 4; i++) g_signal_connect(prefs_widget(moves[i]), "clicked", G_CALLBACK(attribute_move), GINT_TO_POINTER(i)); + g_signal_connect(prefs_widget("textSelectAllButton"), "clicked", G_CALLBACK(attribute_select_all), GINT_TO_POINTER(1)); + g_signal_connect(prefs_widget("textUnselectAllButton"), "clicked", G_CALLBACK(attribute_select_all), GINT_TO_POINTER(0)); + g_signal_connect(prefs_widget("textResetButton"), "clicked", G_CALLBACK(attribute_reset), NULL); + g_object_unref(attributes); +} + +void prefs_text_attributes_clear(void) { gtk_list_store_clear(attributes); } +void prefs_text_attribute_append(const char *key, const char *label, int speak, int braille) { + GtkTreeIter iter; + gtk_list_store_append(attributes, &iter); + gtk_list_store_set(attributes, &iter, ATTRIBUTE_KEY, key, ATTRIBUTE_LABEL, label, + ATTRIBUTE_SPEAK, speak != 0, ATTRIBUTE_BRAILLE, braille != 0, -1); +} +int prefs_text_attribute_count(void) { return gtk_tree_model_iter_n_children(GTK_TREE_MODEL(attributes), NULL); } +char *prefs_text_attribute_key(int row) { + GtkTreeIter iter; + if (!gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(attributes), &iter, NULL, row)) return NULL; + char *key = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(attributes), &iter, ATTRIBUTE_KEY, &key, -1); + return key; +} +int prefs_text_attribute_enabled(int row, int column) { + GtkTreeIter iter; + if (column < ATTRIBUTE_SPEAK || column > ATTRIBUTE_BRAILLE || + !gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(attributes), &iter, NULL, row)) return 0; + gboolean active = FALSE; + gtk_tree_model_get(GTK_TREE_MODEL(attributes), &iter, column, &active, -1); + return active; +} diff --git a/internal/preferences/native_widgets.c b/internal/preferences/native_widgets.c new file mode 100644 index 0000000..c26a1ae --- /dev/null +++ b/internal/preferences/native_widgets.c @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +void prefs_voice_defaults(int kind) { + const char *ids[] = {"rate_default", "pitch_default", "volume_default"}; + const char *server[] = {"Use server default rate", "Use server default pitch", "Use server default volume"}; + const char *inherited[] = {"Use Default voice rate", "Use Default voice pitch", "Use Default voice volume"}; + for (unsigned i = 0; i < G_N_ELEMENTS(ids); i++) { + const char *label = kind == 0 ? server[i] : inherited[i]; + if (kind == 1 && i == 1) label = "Use automatic uppercase pitch"; + GtkButton *button = GTK_BUTTON(prefs_widget(ids[i])); + if (g_strcmp0(gtk_button_get_label(button), label) != 0) gtk_button_set_label(button, label); + } +} + +void prefs_number_set(const char *id, double value, int use_default) { + gchar *check = g_strconcat(id, "_default", NULL); + gtk_range_set_value(GTK_RANGE(prefs_widget(id)), value); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(prefs_widget(check)), use_default); + gtk_widget_set_sensitive(prefs_widget(id), !use_default); + g_free(check); +} +double prefs_number_value(const char *id) { return gtk_range_get_value(GTK_RANGE(prefs_widget(id))); } +int prefs_default(const char *id) { + gchar *check = g_strconcat(id, "_default", NULL); + int result = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(prefs_widget(check))); + g_free(check); return result; +} +void prefs_choice_set(const char *id, int value) { + gchar *name = g_strdup_printf("%s_%d", id, value); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(prefs_widget(name)), TRUE); + g_free(name); +} +int prefs_choice_get(const char *id) { + for (int i = 0; i < 4; i++) { + gchar *name = g_strdup_printf("%s_%d", id, i); + GtkWidget *button = prefs_widget(name); + g_free(name); + if (button && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button))) return i; + } + return -1; +} +void prefs_combo_set(const char *id, int value) { + gtk_combo_box_set_active(GTK_COMBO_BOX(prefs_widget(id)), value); +} +int prefs_combo_get(const char *id) { + return gtk_combo_box_get_active(GTK_COMBO_BOX(prefs_widget(id))); +} + +void prefs_check_set(const char *id, int value) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(prefs_widget(id)), value); } +int prefs_check_get(const char *id) { return gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(prefs_widget(id))); } + +void prefs_range_set(const char *id, double value) { gtk_range_set_value(GTK_RANGE(prefs_widget(id)), value); } +void prefs_spin_set(const char *id, double value) { gtk_spin_button_set_value(GTK_SPIN_BUTTON(prefs_widget(id)), value); } +double prefs_spin_get(const char *id) { return gtk_spin_button_get_value(GTK_SPIN_BUTTON(prefs_widget(id))); } +void prefs_combo_clear(const char *id) { gtk_combo_box_text_remove_all(GTK_COMBO_BOX_TEXT(prefs_widget(id))); } +void prefs_combo_append(const char *id, const char *text) { gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(prefs_widget(id)), text); } +void prefs_hardware_devices_clear(void) { + GtkEntryCompletion *completion = gtk_entry_get_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry"))); + gtk_list_store_clear(GTK_LIST_STORE(gtk_entry_completion_get_model(completion))); +} +void prefs_hardware_device_append(const char *text) { + GtkEntryCompletion *completion = gtk_entry_get_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry"))); + GtkListStore *model = GTK_LIST_STORE(gtk_entry_completion_get_model(completion)); + GtkTreeIter row; + gtk_list_store_append(model, &row); + gtk_list_store_set(model, &row, 0, text, -1); +} +void prefs_hardware_device_set(const char *text) { + gtk_entry_set_text(GTK_ENTRY(prefs_widget("hardwareDeviceEntry")), text); +} +const char *prefs_hardware_device_get(void) { + return gtk_entry_get_text(GTK_ENTRY(prefs_widget("hardwareDeviceEntry"))); +} +void prefs_hardware_sensitivity(int enabled) { + gtk_widget_set_sensitive(prefs_widget("hardwareDeviceEntry"), enabled); + gtk_widget_set_sensitive(prefs_widget("hardwareBaudCombo"), enabled); + gtk_widget_set_sensitive(prefs_widget("speechLanguages"), !enabled); + gtk_widget_set_sensitive(prefs_widget("speechFamilies"), !enabled); +} +void prefs_entry_set(const char *id, const char *text) { gtk_entry_set_text(GTK_ENTRY(prefs_widget(id)), text); } +const char *prefs_entry_get(const char *id) { return gtk_entry_get_text(GTK_ENTRY(prefs_widget(id))); } diff --git a/internal/preferences/ocr.go b/internal/preferences/ocr.go new file mode 100644 index 0000000..226b726 --- /dev/null +++ b/internal/preferences/ocr.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 + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func setOCRValues(value config.OCRSettings) { + id, content := C.CString("ocrLanguageEntry"), C.CString(value.Language) + C.prefs_entry_set(id, content) + C.free(unsafe.Pointer(id)) + C.free(unsafe.Pointer(content)) + setCombo("ocrLayoutCombo", map[int]int{3: 0, 6: 1, 11: 2}[value.Layout]) + setSpin("ocrScaleSpinButton", float64(value.Scale)) + setCheck("ocrGrayscaleCheckButton", value.Grayscale) + setCheck("ocrInvertCheckButton", value.Invert) + setCheck("ocrBlackWhiteCheckButton", value.BlackWhite) + setSpin("ocrBlackWhiteValueSpinButton", float64(value.Threshold)) + setCheck("ocrAutoRefreshCheckButton", value.AutoRefresh) + setSpin("ocrRefreshIntervalSpinButton", float64(value.RefreshInterval)) + setCheck("ocrConfirmClickCheckButton", value.ConfirmClick) + setCheck("ocrCopyToClipboardCheckButton", value.CopyFirstResult) + setCheck("ocrAutoReadAllCheckButton", value.AutoReadAll) +} + +func getOCRValues() config.OCRSettings { + id := C.CString("ocrLanguageEntry") + language := C.GoString(C.prefs_entry_get(id)) + C.free(unsafe.Pointer(id)) + layout := 3 + if index := getCombo("ocrLayoutCombo"); index >= 0 && index < 3 { + layout = []int{3, 6, 11}[index] + } + return config.OCRSettings{ + Language: language, Layout: layout, Scale: int(getSpin("ocrScaleSpinButton")), + Grayscale: getCheck("ocrGrayscaleCheckButton"), Invert: getCheck("ocrInvertCheckButton"), + BlackWhite: getCheck("ocrBlackWhiteCheckButton"), Threshold: int(getSpin("ocrBlackWhiteValueSpinButton")), + AutoRefresh: getCheck("ocrAutoRefreshCheckButton"), RefreshInterval: int(getSpin("ocrRefreshIntervalSpinButton")), + ConfirmClick: getCheck("ocrConfirmClickCheckButton"), CopyFirstResult: getCheck("ocrCopyToClipboardCheckButton"), + AutoReadAll: getCheck("ocrAutoReadAllCheckButton"), + } +} diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui new file mode 100644 index 0000000..cb53dac --- /dev/null +++ b/internal/preferences/preferences.ui @@ -0,0 +1,318 @@ + + + + 0 + 99 + 0.1 + 5 + + + 0 + 9 + 0.1 + 1 + + + 0 + 9 + 0.1 + 1 + + 01000.11 + 0100.11 + 0100.11 + 010.50.050.1 + 060515 + 1100050110 + + 1120 + 101 + + + 0100 + 501 + + 1411 + 0255200110 + 5006000015001001000 + + Cthulhu Preferences + 760 + 560 + 12 + + + vertical + 10 + + + True + True + + + + never + + vertical + 12 + 8 + Keyboard Layout + 812 + _DesktopTrueTrue + _LaptopTrueTruegeneralDesktopButton + + + False + False + Progress _bar output:TrueprogressBarOutputCombo0 + FalseOffBeepSpeakBeep and speak + Report back_ground progress barsTrueTrue + + + + General + + + + never + + vertical + 12 + 8 + Enable _soundsTrueTrue + TrueTruesound_volume_adjustment2rightSound volume + FalseAutomaticPipeWirePulseAudioALSA + FalseDefaultNone + FalseSound and speechSpeech onlySound only + Play sound for _roleTrueTrue + Play sound for _stateTrueTrue + Play sound for p_osition in setTrueTrue + Use stereo _position for object soundsTrueTrue + + + + Sounds + + + + never + + vertical + 12 + 10 + _Enable speechTrueTrue + + 10 + 12 + Speech _system:TruespeechSystems100 + FalseSpeech DispatcherHardware serial synthesizer10 + Speech synthesi_zer:TruespeechServers101 + FalseDefault11 + _Language:TruespeechLanguages102 + FalseDefault12 + _Person:TruespeechFamilies103 + FalseDefault13 + FalseDefaultUppercaseHyperlinkSystem14 + _Voice type:TruevoiceTypesCombo104 + Use server default volumeTrue052 + Vo_lume:Truevolume106 + volume_adjustmentTrue1Trueright16 + Use server default pitchTrue072 + Pi_tch:Truepitch108 + pitch_adjustmentTrue1Trueright18 + Use server default rateTrue092 + _Rate:Truerate1010 + rate_adjustmentTrue1Trueright110 + _Capitalization style:Truecapitalization1011 + FalseNoneSpellSound111 + TrueSerial device path; completion offers detected devices115 + Serial _device:TruehardwareDeviceEntry1015 + Serial baud rate:hardwareBaudCombo1017 + False1200240048009600192003840057600115200117 + + + vertical + 8 + 12 + Verbosity812 + Brie_fTrueTrue + Ver_boseTrueTruespeechBriefButton + + Speech history812 + History _entries:TruespeechHistoryLimitSpinButton + Truespeech_history_limit_adjustmentTrueNumber of spoken items kept in history + + Punctuation Level812 + _NoneTrueTrue + So_meTrueTruepunctuation_3 + M_ostTrueTruepunctuation_3 + _AllTrueTruepunctuation_3 + + Say All88 + FalseSentenceLine + + Reading settingsneverTrueTrue + Indentation812 + SpeechTrue + BeepsTrueindentationSpeechButton + Speech and beepsTrueindentationSpeechButton + + + 0182 + + + + + + + Speech + + + neververtical128 + Enable _brailleTrueTrue + _Abbreviated role namesTrueTrue + Disable end of line symbolTrue + Enable contracted brailleTrue + FalseContraction table for the current language + Enable word wrapTrue + Braille verbosity128 + BriefTrue + VerboseTruebrailleBriefButton + + Selection indicator: NoneTrue + Selection indicator: Dot 7TruebrailleSelectionNoneButton + Selection indicator: Dot 8TruebrailleSelectionNoneButton + Selection indicator: Dots 7 and 8TruebrailleSelectionNoneButton + Link indicator: NoneTrue + Link indicator: Dot 7TruebrailleLinkNoneButton + Link indicator: Dot 8TruebrailleLinkNoneButton + Link indicator: Dots 7 and 8TruebrailleLinkNoneButton + Enable flash messagesTrue + Truebraille_flash_adjustmentFlash message duration in seconds + Messages are persistentTrue + Messages are detailedTrue + + Braille + + neververtical127 + Enable key echoTrue + Enable alphabetic keysTrue + Enable numeric keysTrue + Enable punctuation keysTrue + Enable spaceTrue + Enable modifier keysTrue + Enable function keysTrue + Enable action keysTrue + Enable navigation keysTrue + Enable diacritical keysTrue + Use custom echo voiceTrue + Use custom speech synthesizer for key echoTrue + FalseDefault + FalseDefault + 1echo_rate_adjustmentTrueTrueKey echo rate + 1echo_pitch_adjustmentTrueTrueKey echo pitch + 1echo_volume_adjustmentTrueTrueKey echo volume + Use custom voice for key echoTrue + Use custom voice for character echoTrue + Use custom voice for word echoTrue + Use custom voice for sentence echoTrue + Enable echo by characterTrue + Enable echo by wordTrue + Enable echo by sentenceTrue + + Key Echo + + vertical128 + 110neverTrueTrue + 8 + Edit bindings for layout + Desktop bindingsTrue + Laptop bindingsTruekeyBindingDesktopButton + + Select a command. Enter shortcuts separated by semicolons and press Set binding, for example Cthulhu+Ctrl+F8; Alt+F9. Changes are committed with Apply or OK.True0 + TrueTrueTrue + 8 + _ShortcutsTruekeyBindingEntry + TrueTrue + + start + Set bindingTrue + Clear bindingTrue + Restore default bindingTrue + Restore all bindings for this layoutTrue + + + Key Bindings + + vertical128 + TrueTrue + end_New entryTrueTrue_DeleteTrueTrue + + Pronunciation + + vertical128 + TrueTrue + Select _AllTrueTrue_Unselect AllTrueTrue_ResetTrueTrue + Move to _topTrueTrueMove _upTrueTrueMove _downTrueTrueMove to _bottomTrueTrue + Braille indicator: NoneTrue + Braille indicator: Dot 7TruetextBrailleNoneButton + Braille indicator: Dot 8TruetextBrailleNoneButton + Braille indicator: Dots 7 and 8TruetextBrailleNoneButton + + Text Attributes + + neververtical128 + TrueInstalled language codes, for example eng or fra+engRecognition languages. Leave empty for the installed default. + FalseAutomatic pageSingle text blockSparse text + Trueocr_scale_adjustmentImage scale factor, 1 through 4 + _Grayscale imageTrueTrue + _Invert imageTrueTrue + _Black and white imageTrueTrue + Trueocr_threshold_adjustmentBlack and white threshold, 0 through 255 + Automatically _read the first OCR resultTrueTrue + _Periodically refresh screen OCRTrueTrue + Trueocr_refresh_adjustmentRefresh interval in milliseconds + _Confirm OCR coordinate clicksTrueTrue + Copy first OCR result to _clipboardTrueTrue + + OCR + + vertical128 + Tentacle management changes take effect immediately.True0 + 8 + Repository _URLTruetentacleRepositoryURL + TrueTrue + Add repositoryTrue + + TrueTrueTrue + vertical + 100TrueTrueTrue + + All tentacles + + start6 + InstallTrue + SettingsTrue + RetryTrue + Remove tentacleTrue + Refresh statusTrue + Refresh repositoryTrue + Remove repositoryTrue + + + Tentacles + + + _StatusTruestatus0 + FalseTrue + end8 + _HelpTrueTrue + _ApplyTrueTrue + _CancelTrueTrue + _OKTrueTrueTrue + + + + + diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go new file mode 100644 index 0000000..e9ca15d --- /dev/null +++ b/internal/preferences/protocol.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package preferences implements the isolated GTK preferences process and its +// bounded, typed protocol. The UI never opens settings files or speech sockets. +package preferences + +import ( + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/speech" + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +type Values struct { + Pronunciations []config.PronunciationEntry + TextAttributes config.TextAttributeSettings + OCR config.OCRSettings + ProgressBar config.ProgressBarSettings + KeyboardLayout int + CthulhuModifierKeys []string + KeyBindings []input.BindingOverride + TimeFormat string + DateFormat string + EnableSound bool + EnableSpeech bool + SpeechHistoryLimit int + SpeechVerbosityLevel int + SayAllStyle string + EnableBraille bool + EnableContractedBraille bool + BrailleContractionTable string + Braille config.BrailleOptions + SoundVolume float64 + SoundSink string + SoundTheme string + PlaySoundForRole bool + PlaySoundForState bool + PlaySoundForPositionInSet bool + SpatializeObjectSounds bool + RoleSoundPresentation string + EnableKeyEcho bool + EnableAlphabeticKeys bool + EnableNumericKeys bool + EnablePunctuationKeys bool + EnableSpace bool + EnableModifierKeys bool + EnableFunctionKeys bool + EnableActionKeys bool + EnableNavigationKeys bool + EnableDiacriticalKeys bool + EnableEchoByCharacter bool + EnableEchoByWord bool + EnableEchoBySentence bool + UseCustomEchoVoice bool + UseCustomEchoSpeechServer bool + UseCustomEchoForKey bool + UseCustomEchoForCharacter bool + UseCustomEchoForWord bool + UseCustomEchoForSentence bool + EchoVoice config.Voice + EchoSpeechServer string + + Rate *float64 + Pitch *float64 + Volume *float64 + SpeechServerFactory string + HardwareSpeechDevice string + HardwareSpeechBaudRate int + SpeechOutputModule string + SpeechVoiceFamily *config.VoiceFamily + UppercaseVoice config.Voice + HyperlinkVoice config.Voice + SystemVoice config.Voice + VoiceType string // Preview selection; never persisted as reader state. + Punctuation int + Capitalization string + Reading config.ReadingSettings + SpeakDescription bool +} + +type Request struct { + Tentacle *tentacle.ManagementRequest + Application string + Values Values + Save bool + Close bool + // Preview applies the default voice controls to the running speech + // backend without committing session or persistent settings. + Preview bool + // Cancel restores the last committed settings after a live preview. + Cancel bool +} + +type Response struct { + Tentacles *tentacle.Snapshot `json:",omitempty"` + ChooseApplication bool + Catalog speech.Catalog + CatalogPending bool // More catalog parts follow before the UI can initialize. + Application string + Present bool + Close bool + Values Values + CanSave bool + Message string + Preview bool + // Accepted distinguishes committed session values from rejected drafts. + Accepted bool +} diff --git a/internal/preferences/protocol_test.go b/internal/preferences/protocol_test.go new file mode 100644 index 0000000..01b1099 --- /dev/null +++ b/internal/preferences/protocol_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestSoundValuesDefaultAndMerge(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + if !values.EnableSound || !values.PlaySoundForRole || !values.PlaySoundForState || + !values.PlaySoundForPositionInSet || + values.ProgressBar.OutputMode != "beep" || values.SpatializeObjectSounds { + t.Fatalf("default sound checkboxes = %+v", values) + } + + values.EnableSound = false + values.EnableBraille = true + values.EnableContractedBraille = true + values.BrailleContractionTable = "en-us-g2.ctb" + values.SoundVolume = 0.75 + values.SoundSink = "pulse" + values.SoundTheme = "none" + values.PlaySoundForRole = false + values.PlaySoundForState = false + values.PlaySoundForPositionInSet = false + values.SpatializeObjectSounds = true + values.ProgressBar.OutputMode = "off" + values.ProgressBar.ReportBackground = true + values.RoleSoundPresentation = "speech_only" + + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if got := FromSettings(merged); !reflect.DeepEqual(got, values) { + t.Fatalf("sound values did not round trip:\n got %+v\nwant %+v", got, values) + } +} + +func TestReadingValuesRoundTrip(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + if values.Reading.OnlySpeakDisplayedText || !values.Reading.SpeakBlankLines || + !values.SpeakDescription || values.Reading.ReadFullRowInSpreadSheet || + !values.EnableSpeech || !values.Reading.EnablePauseBreaks || + values.Reading.SpeakNumbersAsDigits || values.Reading.SpeakMultiCaseStringsAsWords || + values.Reading.EnableIndentation { + t.Fatalf("default reading values = %+v", values) + } + + values.EnableSpeech = false + values.SayAllStyle = "sentence" + values.SpeechVerbosityLevel = 0 + values.Reading.OnlySpeakDisplayedText = true + values.Reading.EnablePauseBreaks = false + values.Reading.SpeakNumbersAsDigits = true + values.Reading.SpeakMultiCaseStringsAsWords = true + values.Reading.EnableIndentation = true + values.Reading.SpeakBlankLines = false + values.Reading.EnableMnemonicSpeaking = true + values.Reading.EnablePositionSpeaking = true + values.Reading.EnableTutorialMessages = true + values.Reading.MessagesAreDetailed = false + values.Reading.UseColorNames = false + values.Reading.PresentTooltips = true + values.Reading.EnableMouseReview = true + values.Reading.SpeakMisspelledIndicator = false + values.Reading.SpeakContextBlockquote = false + values.Reading.SpeakContextList = false + values.Reading.SpeakContextTable = false + values.Reading.SpeakContextPanel = false + values.Reading.SpeakContextLandmark = false + values.Reading.SpeakContextNonLandmarkForm = false + values.Reading.SayAllContextBlockquote = false + values.Reading.SayAllContextList = false + values.Reading.SayAllContextTable = false + values.Reading.SayAllContextPanel = false + values.Reading.SayAllContextLandmark = false + values.Reading.SayAllContextNonLandmarkForm = false + values.Reading.ReadFullRowInGUITable = false + values.Reading.ReadFullRowInDocumentTable = false + values.Reading.ReadFullRowInSpreadSheet = true + values.SpeakDescription = false + + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if got := FromSettings(merged); !reflect.DeepEqual(got, values) { + t.Fatalf("reading values did not round trip:\n got %+v\nwant %+v", got, values) + } +} + +func TestSpeechValuesRoundTrip(t *testing.T) { + settings := config.DefaultSettings() + settings.SpeechHistoryLimit = 1000 + family := &config.VoiceFamily{Name: "Generic Voice", Lang: "en", Dialect: "US", Variant: "male"} + settings.SpeechOutputModule = "espeak-ng" + settings.DefaultVoice.Family = family + + values := FromSettings(settings) + if values.SpeechOutputModule != "espeak-ng" || values.SpeechVoiceFamily != family || values.SpeechHistoryLimit != 1000 { + t.Fatalf("speech values = %+v", values) + } + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if merged.SpeechOutputModule != "espeak-ng" || merged.DefaultVoice.Family != family || merged.SpeechHistoryLimit != 1000 { + t.Fatalf("merged speech values = %+v", merged) + } +} + +func TestKeyboardValuesMergeAndOwnership(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + values.KeyboardLayout = 2 + values.CthulhuModifierKeys = []string{"Caps_Lock", "Insert"} + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if merged.KeyboardLayout != 2 || !reflect.DeepEqual(merged.CthulhuModifierKeys, values.CthulhuModifierKeys) { + t.Fatalf("keyboard settings were not merged: %+v", merged) + } + values.CthulhuModifierKeys[0] = "KP_Insert" + if merged.CthulhuModifierKeys[0] != "Caps_Lock" { + t.Fatal("merged modifiers alias the UI values") + } + values.CthulhuModifierKeys = []string{} + merged, err = values.Merge(settings) + if err != nil || len(merged.CthulhuModifierKeys) != 0 { + t.Fatalf("cannot disable all modifiers: %v", err) + } +} diff --git a/internal/preferences/reading.go b/internal/preferences/reading.go new file mode 100644 index 0000000..89dbd24 --- /dev/null +++ b/internal/preferences/reading.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "unsafe" +) + +func setReading(values Values) { + for _, item := range readingChecks(&values) { + id := C.CString(item.id) + C.prefs_reading_set(id, boolean(*item.value)) + C.free(unsafe.Pointer(id)) + } +} + +func getReading(values *Values) { + for _, item := range readingChecks(values) { + id := C.CString(item.id) + *item.value = C.prefs_reading_get(id) != 0 + C.free(unsafe.Pointer(id)) + } +} + +func readingChecks(values *Values) []struct { + id string + value *bool +} { + return []struct { + id string + value *bool + }{ + {"only_speak_displayed_text", &values.Reading.OnlySpeakDisplayedText}, + {"automatically_read_title_changes", &values.Reading.AutomaticallyReadTitleChanges}, + {"automatically_read_clipboard_changes", &values.Reading.AutomaticallyReadClipboardChanges}, + {"enable_pause_breaks", &values.Reading.EnablePauseBreaks}, + {"speak_numbers_as_digits", &values.Reading.SpeakNumbersAsDigits}, + {"speak_multicase_strings_as_words", &values.Reading.SpeakMultiCaseStringsAsWords}, + {"enable_indentation", &values.Reading.EnableIndentation}, + {"speak_blank_lines", &values.Reading.SpeakBlankLines}, + {"enable_mnemonic_speaking", &values.Reading.EnableMnemonicSpeaking}, + {"enable_position_speaking", &values.Reading.EnablePositionSpeaking}, + {"enable_tutorial_messages", &values.Reading.EnableTutorialMessages}, + {"messages_are_detailed", &values.Reading.MessagesAreDetailed}, + {"use_color_names", &values.Reading.UseColorNames}, + {"present_tooltips", &values.Reading.PresentTooltips}, + {"enable_mouse_review", &values.Reading.EnableMouseReview}, + {"speak_misspelled_indicator", &values.Reading.SpeakMisspelledIndicator}, + {"speak_context_blockquote", &values.Reading.SpeakContextBlockquote}, + {"speak_context_list", &values.Reading.SpeakContextList}, + {"speak_context_table", &values.Reading.SpeakContextTable}, + {"speak_context_panel", &values.Reading.SpeakContextPanel}, + {"speak_context_landmark", &values.Reading.SpeakContextLandmark}, + {"speak_context_non_landmark_form", &values.Reading.SpeakContextNonLandmarkForm}, + {"say_all_context_blockquote", &values.Reading.SayAllContextBlockquote}, + {"say_all_context_list", &values.Reading.SayAllContextList}, + {"say_all_context_table", &values.Reading.SayAllContextTable}, + {"say_all_context_panel", &values.Reading.SayAllContextPanel}, + {"say_all_context_landmark", &values.Reading.SayAllContextLandmark}, + {"say_all_context_non_landmark_form", &values.Reading.SayAllContextNonLandmarkForm}, + {"read_full_row_in_gui_table", &values.Reading.ReadFullRowInGUITable}, + {"read_full_row_in_document_table", &values.Reading.ReadFullRowInDocumentTable}, + {"read_full_row_in_spreadsheet", &values.Reading.ReadFullRowInSpreadSheet}, + {"speak_description", &values.SpeakDescription}, + } +} diff --git a/internal/preferences/session.go b/internal/preferences/session.go new file mode 100644 index 0000000..2048de4 --- /dev/null +++ b/internal/preferences/session.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +// uiSession lives for one helper window. Only the locked GTK thread in Run +// accesses these fields; pipe readers deliver immutable responses by channel. +type uiSession struct { + progressBar config.ProgressBarSettings + catalog speech.Catalog + contractionTables []braille.TableOption + soundThemes []string + echoModules []string + echoFamilies []*config.VoiceFamily + activeEchoModule string + speechModules []string + speechLanguages []string + speechFamilies []*config.VoiceFamily + activeSpeechSystem int + softwareModule string + hardwareModule string + activeSpeechModule string + activeSpeechLanguage string + voiceDrafts [4]config.Voice + activeVoiceType int + bindingDraft []input.BindingOverride + bindingRows []input.BindingRow + bindingLayout int +} diff --git a/internal/preferences/sound.go b/internal/preferences/sound.go new file mode 100644 index 0000000..e95dec0 --- /dev/null +++ b/internal/preferences/sound.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "errors" +) + +func soundChecks(values *Values) []struct { + id string + value *bool +} { + return []struct { + id string + value *bool + }{ + {"enableSoundCheckButton", &values.EnableSound}, + {"playSoundForRoleCheckButton", &values.PlaySoundForRole}, + {"playSoundForStateCheckButton", &values.PlaySoundForState}, + {"playSoundForPositionInSetCheckButton", &values.PlaySoundForPositionInSet}, + {"spatializeObjectSoundsCheckButton", &values.SpatializeObjectSounds}, + } +} + +func (session *uiSession) setSoundTheme(theme string) { + session.soundThemes = []string{"default", "none"} + selected := 0 + if theme == "none" { + selected = 1 + } else if theme != "" && theme != "default" { + selected = len(session.soundThemes) + session.soundThemes = append(session.soundThemes, theme) + } + labels := make([]string, len(session.soundThemes)) + for i, value := range session.soundThemes { + if value == "default" { + labels[i] = "Default" + } else if value == "none" { + labels[i] = "None" + } else { + labels[i] = value + } + } + comboItems("soundThemeCombo", labels, selected) +} + +func set_sound_checks(values Values) { + for _, check := range soundChecks(&values) { + setCheck(check.id, *check.value) + } +} + +func (session *uiSession) set_sound_values(values Values) { + setRange("soundVolumeScale", values.SoundVolume) + setCombo("soundSinkCombo", map[string]int{"auto": 0, "pipewire": 1, "pulse": 2, "alsa": 3}[values.SoundSink]) + session.setSoundTheme(values.SoundTheme) + setCombo("roleSoundPresentationCombo", map[string]int{"sound_and_speech": 0, "speech_only": 1, "sound_only": 2}[values.RoleSoundPresentation]) +} + +func get_sound_checks(values *Values) { + for _, check := range soundChecks(values) { + *check.value = getCheck(check.id) + } +} + +func (session *uiSession) get_sound_values(values *Values) error { + values.SoundVolume = getRange("soundVolumeScale") + sink, theme, presentation := getCombo("soundSinkCombo"), getCombo("soundThemeCombo"), getCombo("roleSoundPresentationCombo") + if sink < 0 || sink > 3 || theme < 0 || theme >= len(session.soundThemes) || presentation < 0 || presentation > 2 { + return errors.New("Choose valid sound output settings.") + } + values.SoundSink = []string{"auto", "pipewire", "pulse", "alsa"}[sink] + values.SoundTheme = session.soundThemes[theme] + values.RoleSoundPresentation = []string{"sound_and_speech", "speech_only", "sound_only"}[presentation] + return nil +} diff --git a/internal/preferences/speech.go b/internal/preferences/speech.go new file mode 100644 index 0000000..8d2e716 --- /dev/null +++ b/internal/preferences/speech.go @@ -0,0 +1,401 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func (session *uiSession) setSpeechVoice(values Values) { + session.voiceDrafts = [4]config.Voice{ + {Rate: values.Rate, AveragePitch: values.Pitch, Gain: values.Volume, Family: values.SpeechVoiceFamily}, + values.UppercaseVoice, values.HyperlinkVoice, values.SystemVoice, + } + setCombo("voiceTypesCombo", session.activeVoiceType) + session.activeSpeechSystem = 0 + if values.SpeechServerFactory == "hardwarefactory" { + session.activeSpeechSystem = 1 + } + setCombo("speechSystems", session.activeSpeechSystem) + devices := []string{} + for _, pattern := range []string{"/dev/ttyS*", "/dev/ttyUSB*", "/dev/ttyACM*", "/dev/serial/by-id/*"} { + matches, _ := filepath.Glob(pattern) + devices = append(devices, matches...) + } + sort.Strings(devices) + C.prefs_hardware_devices_clear() + for _, device := range devices { + label := C.CString(device) + C.prefs_hardware_device_append(label) + C.free(unsafe.Pointer(label)) + } + device := C.CString(values.HardwareSpeechDevice) + C.prefs_hardware_device_set(device) + C.free(unsafe.Pointer(device)) + baudIndex := 3 + for index, baud := range hardwareBaudRates { + if baud == values.HardwareSpeechBaudRate { + baudIndex = index + } + } + setCombo("hardwareBaudCombo", baudIndex) + C.prefs_hardware_sensitivity(C.int(session.activeSpeechSystem)) + session.populateSpeechModules(values) + session.load_voice_editor() +} + +var hardwareBaudRates = []int{1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200} + +func (session *uiSession) populateSpeechModules(values Values) { + if session.activeSpeechSystem == 1 { + session.speechModules = []string{"litetalk", "doubletalk", "tripletalk", "dectalk"} + selected := 0 + for index, module := range session.speechModules { + if module == values.SpeechOutputModule { + selected = index + } + } + comboItems("speechServers", []string{"LiteTalk", "DoubleTalk", "TripleTalk", "DECtalk"}, selected) + session.activeSpeechModule = session.speechModules[selected] + comboItems("speechLanguages", []string{"Default"}, 0) + comboItems("speechFamilies", []string{"Default"}, 0) + return + } + session.speechModules = []string{""} + for module := range session.catalog { + if module != "" { + session.speechModules = append(session.speechModules, module) + } + } + sort.Strings(session.speechModules[1:]) + selected := 0 + if values.SpeechOutputModule != "" { + selected = -1 + for i, module := range session.speechModules { + if module == values.SpeechOutputModule { + selected = i + } + } + if selected < 0 { + selected = len(session.speechModules) + session.speechModules = append(session.speechModules, values.SpeechOutputModule) + } + } + labels := append([]string(nil), session.speechModules...) + labels[0] = "Default" + comboItems("speechServers", labels, selected) + session.populateSpeechLanguages(values.SpeechOutputModule, "", values.SpeechVoiceFamily) +} + +func (session *uiSession) populateSpeechLanguages(module, language string, selected *config.VoiceFamily) { + session.activeSpeechModule = module + session.speechLanguages = []string{""} + seen := map[string]bool{"": true} + for _, voice := range session.catalog[module] { + if seen[voice.Language] { + continue + } + seen[voice.Language] = true + session.speechLanguages = append(session.speechLanguages, voice.Language) + } + index := 0 + if selected != nil { + language = strings.TrimSuffix(selected.Lang+"-"+selected.Dialect, "-") + } + for i, candidate := range session.speechLanguages { + if strings.EqualFold(candidate, language) { + index = i + break + } + } + if selected != nil && index == 0 && language != "" { + session.speechLanguages = append(session.speechLanguages, language) + index = len(session.speechLanguages) - 1 + } + labels := append([]string(nil), session.speechLanguages...) + labels[0] = "Default" + comboItems("speechLanguages", labels, index) + session.populateSpeechFamilies(module, session.speechLanguages[index], selected) +} + +func (session *uiSession) populateSpeechFamilies(module, language string, selected *config.VoiceFamily) { + session.activeSpeechLanguage = language + session.speechFamilies = []*config.VoiceFamily{nil} + labels := []string{"Default"} + index := 0 + voices := session.catalog[module] + if language == "" { + voices = session.default_voices(module) + } + for _, voice := range voices { + if language != "" && !strings.EqualFold(voice.Language, language) { + continue + } + family := &config.VoiceFamily{Name: voice.Name, Lang: voice.Language, Variant: voice.Variant} + session.speechFamilies = append(session.speechFamilies, family) + labels = append(labels, voice.Name+" ("+voice.Language+")") + if selected != nil && selected.Name == voice.Name && + strings.EqualFold(strings.TrimSuffix(selected.Lang+"-"+selected.Dialect, "-"), voice.Language) { + index = len(labels) - 1 + } + } + if selected != nil && selected.Name != "" && index == 0 { + index = len(labels) + session.speechFamilies = append(session.speechFamilies, selected) + labels = append(labels, selected.Name+" ("+selected.Lang+")") + } + comboItems("speechFamilies", labels, index) +} + +func (session *uiSession) selectedSpeechModule() string { + i := getCombo("speechServers") + if i >= 0 && i < len(session.speechModules) { + return session.speechModules[i] + } + return "" +} + +func (session *uiSession) selectedSpeechFamily() *config.VoiceFamily { + i := getCombo("speechFamilies") + if i >= 0 && i < len(session.speechFamilies) { + if family := session.speechFamilies[i]; family != nil { + return family + } + languageIndex := getCombo("speechLanguages") + if languageIndex > 0 && languageIndex < len(session.speechLanguages) { + return &config.VoiceFamily{Lang: session.speechLanguages[languageIndex]} + } + } + return nil +} + +func (session *uiSession) refreshSpeechVoices() { + voiceType := getCombo("voiceTypesCombo") + if voiceType >= 0 && voiceType < len(session.voiceDrafts) && voiceType != session.activeVoiceType { + voice, err := session.read_voice_editor() + if err != nil { + status(err.Error(), true) + setCombo("voiceTypesCombo", session.activeVoiceType) + return + } + session.voiceDrafts[session.activeVoiceType] = voice + session.activeVoiceType = voiceType + session.load_voice_editor() + return + } + system := getCombo("speechSystems") + if system != session.activeSpeechSystem { + if voice, err := session.read_voice_editor(); err == nil { + session.voiceDrafts[session.activeVoiceType] = voice + } + if session.activeSpeechSystem == 0 { + session.softwareModule = session.selectedSpeechModule() + } else { + session.hardwareModule = session.selectedSpeechModule() + } + session.activeSpeechSystem = system + module := session.softwareModule + if system == 1 { + module = session.hardwareModule + } + session.populateSpeechModules(Values{SpeechOutputModule: module}) + C.prefs_hardware_sensitivity(C.int(system)) + if system == 0 { + session.reconcile_voice_families(session.selectedSpeechModule()) + session.load_voice_editor() + } + } + if system == 1 { + return + } + module := session.selectedSpeechModule() + if module != session.activeSpeechModule { + if voice, err := session.read_voice_editor(); err == nil { + session.voiceDrafts[session.activeVoiceType] = voice + } + session.reconcile_voice_families(module) + session.populateSpeechLanguages(module, session.activeSpeechLanguage, session.voiceDrafts[session.activeVoiceType].Family) + return + } + languageIndex := getCombo("speechLanguages") + if languageIndex < 0 || languageIndex >= len(session.speechLanguages) { + return + } + language := session.speechLanguages[languageIndex] + if language != session.activeSpeechLanguage { + session.populateSpeechFamilies(module, language, nil) + } +} + +func (session *uiSession) reconcile_voice_families(module string) { + for index, voice := range session.voiceDrafts { + family := voice.Family + if family == nil { + continue + } + language := strings.TrimSuffix(family.Lang+"-"+family.Dialect, "-") + languageFound, exact := false, false + for _, candidate := range session.catalog[module] { + if strings.EqualFold(candidate.Language, language) { + languageFound = true + if candidate.Name == family.Name { + exact = true + break + } + } + } + if exact { + continue + } + if languageFound { + session.voiceDrafts[index].Family = &config.VoiceFamily{Lang: language} + } else { + session.voiceDrafts[index].Family = nil + } + } +} + +func set_speech_support(values Values) { + setCheck("speechSupportCheckButton", values.EnableSpeech) + setSpin("speechHistoryLimitSpinButton", float64(values.SpeechHistoryLimit)) + setCheck("speechBriefButton", values.SpeechVerbosityLevel == 0) + setCheck("speechVerboseButton", values.SpeechVerbosityLevel != 0) + setCombo("sayAllStyle", map[string]int{"sentence": 0, "line": 1}[values.SayAllStyle]) + mode := values.Reading.IndentationPresentationMode + setCheck("indentationSpeechButton", mode == "" || mode == "speech") + setCheck("indentationBeepsButton", mode == "beeps") + setCheck("indentationSpeechAndBeepsButton", mode == "both") +} + +func set_speech_numbers(values Values, inherited config.Voice) { + for _, item := range []struct { + id string + value *float64 + inherited *float64 + fallback float64 + }{ + {"rate", values.Rate, inherited.Rate, 50}, {"pitch", values.Pitch, inherited.AveragePitch, 5}, {"volume", values.Volume, inherited.Gain, 5}, + } { + id := C.CString(item.id) + value := item.fallback + if item.inherited != nil { + value = *item.inherited + } + if item.value != nil { + value = *item.value + } + C.prefs_number_set(id, C.double(value), boolean(item.value == nil)) + C.free(unsafe.Pointer(id)) + } +} + +func set_speech_presentation(values Values) { + setChoice("punctuation", values.Punctuation) + setCombo("capitalization", map[string]int{"none": 0, "spell": 1, "icon": 2}[values.Capitalization]) + setReading(values) +} + +func get_speech_support(values *Values) { + values.EnableSpeech = getCheck("speechSupportCheckButton") + values.SpeechHistoryLimit = int(getSpin("speechHistoryLimitSpinButton")) + values.Reading.IndentationPresentationMode = "speech" + if getCheck("indentationBeepsButton") { + values.Reading.IndentationPresentationMode = "beeps" + } else if getCheck("indentationSpeechAndBeepsButton") { + values.Reading.IndentationPresentationMode = "both" + } + values.SpeechVerbosityLevel = 1 + if getCheck("speechBriefButton") { + values.SpeechVerbosityLevel = 0 + } + if getCombo("sayAllStyle") == 0 { + values.SayAllStyle = "sentence" + } else { + values.SayAllStyle = "line" + } +} + +func (session *uiSession) get_speech_values(values *Values) error { + voice, err := session.read_voice_editor() + if err != nil { + return err + } + session.voiceDrafts[session.activeVoiceType] = voice + defaultVoice := session.voiceDrafts[0] + values.Rate, values.Pitch, values.Volume = defaultVoice.Rate, defaultVoice.AveragePitch, defaultVoice.Gain + values.SpeechVoiceFamily = defaultVoice.Family + values.UppercaseVoice, values.HyperlinkVoice, values.SystemVoice = session.voiceDrafts[1], session.voiceDrafts[2], session.voiceDrafts[3] + values.VoiceType = []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType] + values.EchoVoice, values.EchoSpeechServer = session.getEchoVoice() + values.SpeechServerFactory = "speechdispatcherfactory" + if session.activeSpeechSystem == 1 { + values.SpeechServerFactory = "hardwarefactory" + } + values.HardwareSpeechDevice = C.GoString(C.prefs_hardware_device_get()) + indexBaud := getCombo("hardwareBaudCombo") + if indexBaud < 0 || indexBaud >= len(hardwareBaudRates) { + return errors.New("Choose a serial baud rate.") + } + values.HardwareSpeechBaudRate = hardwareBaudRates[indexBaud] + values.SpeechOutputModule = session.selectedSpeechModule() + values.Punctuation = getChoice("punctuation") + index := getCombo("capitalization") + if index < 0 || index > 2 { + return errors.New("Choose a capitalization style.") + } + values.Capitalization = []string{"none", "spell", "icon"}[index] + return nil +} + +func (session *uiSession) load_voice_editor() { + voice := session.voiceDrafts[session.activeVoiceType] + settings := config.Settings{DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]} + kind := []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType] + set_speech_numbers(Values{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}, config.EffectiveVoice(settings, kind)) + C.prefs_voice_defaults(C.int(session.activeVoiceType)) + if session.activeSpeechSystem != 1 { + session.populateSpeechLanguages(session.selectedSpeechModule(), "", voice.Family) + } +} + +func (session *uiSession) read_voice_editor() (config.Voice, error) { + var voice config.Voice + for _, item := range []struct { + id string + target **float64 + max float64 + }{ + {"rate", &voice.Rate, 99}, {"pitch", &voice.AveragePitch, 9}, {"volume", &voice.Gain, 9}, + } { + id := C.CString(item.id) + useDefault := C.prefs_default(id) != 0 + value := float64(C.prefs_number_value(id)) + C.free(unsafe.Pointer(id)) + if useDefault { + continue + } + if !(value >= 0 && value <= item.max) { + return voice, fmt.Errorf("Enter a %s between 0 and %.0f.", item.id, item.max) + } + *item.target = &value + } + if session.activeSpeechSystem != 1 { + voice.Family = session.selectedSpeechFamily() + } + return voice, nil +} diff --git a/internal/preferences/tentacles.go b/internal/preferences/tentacles.go new file mode 100644 index 0000000..8f0743b --- /dev/null +++ b/internal/preferences/tentacles.go @@ -0,0 +1,282 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "reflect" + "slices" + "strings" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +type tentacleRow struct { + key string + status tentacle.EntryStatus + installed bool +} + +type tentaclePage struct { + snapshot *tentacle.Snapshot + rows []tentacleRow + origins []string + selectedKey string +} + +func tentacle_rows(snapshot *tentacle.Snapshot, origin string) []tentacleRow { + if snapshot == nil { + return nil + } + var rows []tentacleRow + seen := make(map[string]bool) + repositoryIDs := make(map[string]string) + for _, repository := range snapshot.Repositories { + repositoryIDs[repository.URL] = repository.ID + } + for _, status := range snapshot.Entries { + entry := status.Entry + localID := entry.LocalID + if localID == "" { + localID = entry.ID + } + key := entry.Repository + "\x00" + entry.RepositoryID + "\x00" + localID + seen[key] = true + if origin == "" || entry.Repository == origin && entry.RepositoryID == repositoryIDs[origin] { + rows = append(rows, tentacleRow{key: key, status: status, installed: true}) + } + } + for _, repository := range snapshot.Repositories { + if origin != "" && repository.URL != origin { + continue + } + for _, manifest := range repository.Tentacles { + key := repository.URL + "\x00" + repository.ID + "\x00" + manifest.ID + if seen[key] { + continue + } + status := tentacle.EntryStatus{ + Entry: tentacle.Entry{ID: manifest.ID, LocalID: manifest.ID, Name: manifest.Name, Version: manifest.Version, Repository: repository.URL, RepositoryID: repository.ID}, + State: "Available", + Message: manifest.Description, + } + rows = append(rows, tentacleRow{key: key, status: status}) + } + } + return rows +} + +func (page *tentaclePage) selected() *tentacleRow { + row := int(C.prefs_tentacle_selected()) + if row < 0 || row >= len(page.rows) { + return nil + } + return &page.rows[row] +} + +func (page *tentaclePage) origin() string { + index := int(C.prefs_tentacle_repository_selected()) - 1 + if index < 0 || index >= len(page.origins) { + return "" + } + return page.origins[index] +} + +func (page *tentaclePage) controls() { + selected := page.selected() + installed, enabled := false, false + if selected != nil { + installed, enabled = selected.installed, selected.status.Entry.Enabled + } + available := page.snapshot != nil && !page.snapshot.Suppressed && page.snapshot.Message == "" + C.prefs_tentacle_controls(boolean(available), boolean(installed), boolean(enabled), boolean(page.origin() != "")) +} + +func (page *tentaclePage) render() { + page.rows = tentacle_rows(page.snapshot, page.origin()) + C.prefs_tentacles_clear() + selectedRow := -1 + for index, row := range page.rows { + entry := row.status.Entry + name := entry.Name + if name == "" { + name = entry.ID + } + repository := tentacle.RepositoryLabel(entry.Repository) + if entry.Repository == "" { + repository = "Local development" + } + texts := []*C.char{C.CString(name), C.CString(row.status.State), C.CString(entry.Version), C.CString(repository), C.CString(row.status.Message), C.CString(strings.Join(row.status.Shortcuts, "; "))} + C.prefs_tentacle_append(boolean(entry.Enabled), texts[0], texts[1], texts[2], texts[3], texts[4], texts[5]) + for _, text := range texts { + C.free(unsafe.Pointer(text)) + } + if row.key == page.selectedKey { + selectedRow = index + } + } + C.prefs_tentacle_select(C.int(selectedRow)) + page.controls() +} + +func (page *tentaclePage) populate(snapshot *tentacle.Snapshot) { + if selected := page.selected(); selected != nil { + page.selectedKey = selected.key + } + origin := page.origin() + var origins []string + if snapshot != nil { + for _, repository := range snapshot.Repositories { + origins = append(origins, repository.URL) + } + } + if !reflect.DeepEqual(page.origins, origins) { + C.prefs_tentacle_repositories_clear() + selectedPage := 0 + labels := make(map[string]int) + for _, value := range origins { + labels[tentacle.RepositoryLabel(value)]++ + } + for index, value := range origins { + label := tentacle.RepositoryLabel(value) + if labels[label] > 1 { + label = value + } + text := C.CString(label) + C.prefs_tentacle_repository_append(text) + C.free(unsafe.Pointer(text)) + if value == origin { + selectedPage = index + 1 + } + } + page.origins = origins + C.prefs_tentacle_repository_select(C.int(selectedPage)) + } + page.snapshot = 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 !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." + } + 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("") + C.prefs_entry_set(id, empty) + C.free(unsafe.Pointer(id)) + C.free(unsafe.Pointer(empty)) + } + page.controls() +} diff --git a/internal/preferences/tentacles_test.go b/internal/preferences/tentacles_test.go new file mode 100644 index 0000000..909db52 --- /dev/null +++ b/internal/preferences/tentacles_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package preferences + +import ( + "git.stormux.org/storm/cthulhu/internal/tentacle" + "testing" +) + +func TestTentacleRepositoryViewsAndRemovedSource(t *testing.T) { + first := "https://example.org/team/first" + second := "https://other.example.org/team/second" + snapshot := &tentacle.Snapshot{ + Entries: []tentacle.EntryStatus{{Entry: tentacle.Entry{ID: "installed", LocalID: "echo", Repository: first, RepositoryID: "example.first", Enabled: true}}, {Entry: tentacle.Entry{ID: "local"}}}, + Repositories: []tentacle.Repository{ + {URL: first, ID: "example.first", Tentacles: []tentacle.Manifest{{ID: "echo"}, {ID: "other"}}}, + {URL: second, ID: "example.second", Tentacles: []tentacle.Manifest{{ID: "echo"}}}, + }, + } + all := tentacle_rows(snapshot, "") + if len(all) != 4 || !all[0].installed || !all[0].status.Entry.Enabled || all[2].installed || all[0].key == all[3].key { + t.Fatalf("All view: %+v", all) + } + filtered := tentacle_rows(snapshot, first) + if len(filtered) != 2 || filtered[0].status.Entry.ID != "installed" || filtered[1].status.Entry.ID != "other" { + t.Fatalf("repository view: %+v", filtered) + } + snapshot.Repositories[0].ID = "example.replacement" + if replacement := tentacle_rows(snapshot, first); len(replacement) != 2 || replacement[0].installed { + t.Fatalf("replacement source claimed old installation: %+v", replacement) + } + snapshot.Repositories = snapshot.Repositories[1:] + remaining := tentacle_rows(snapshot, "") + if len(remaining) != 3 || remaining[0].status.Entry.ID != "installed" { + t.Fatalf("removed source lost installed entry: %+v", remaining) + } + if rows := tentacle_rows(nil, ""); len(rows) != 0 { + t.Fatal(rows) + } +} diff --git a/internal/preferences/text_attributes.go b/internal/preferences/text_attributes.go new file mode 100644 index 0000000..124c046 --- /dev/null +++ b/internal/preferences/text_attributes.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "strings" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +var textAttributeLabels = map[string]string{ + "size": "Font size", "family-name": "Font family", "weight": "Weight", + "indent": "Indent", "underline": "Underline", "strikethrough": "Strikethrough", + "justification": "Justification", "style": "Style", "paragraph-style": "Paragraph style", + "text-spelling": "Spelling", "fg-color": "Foreground color", "bg-color": "Background color", +} + +func setPronunciations(entries []config.PronunciationEntry) { + C.prefs_pronunciation_clear() + for _, entry := range entries { + word, replacement := C.CString(entry.Word), C.CString(entry.Replacement) + C.prefs_pronunciation_append(word, replacement) + C.free(unsafe.Pointer(word)) + C.free(unsafe.Pointer(replacement)) + } +} + +func getPronunciations() ([]config.PronunciationEntry, error) { + var entries []config.PronunciationEntry + for row := 0; row < int(C.prefs_pronunciation_count()); row++ { + word := C.prefs_pronunciation_value(C.int(row), 0) + replacement := C.prefs_pronunciation_value(C.int(row), 1) + entry := config.PronunciationEntry{Word: strings.TrimSpace(C.GoString(word)), Replacement: C.GoString(replacement)} + C.g_free(C.gpointer(word)) + C.g_free(C.gpointer(replacement)) + if entry.Word == "" && entry.Replacement == "" { + continue + } + if entry.Word == "" || entry.Replacement == "" { + return nil, errors.New("each pronunciation entry needs a word and a replacement") + } + entries = append(entries, entry) + } + return entries, nil +} + +func setTextAttributes(settings config.TextAttributeSettings) { + C.prefs_text_attributes_clear() + for _, choice := range settings.Choices { + key, label := C.CString(choice.Key), C.CString(textAttributeLabels[choice.Key]) + C.prefs_text_attribute_append(key, label, boolean(choice.Speak), boolean(choice.Braille)) + C.free(unsafe.Pointer(key)) + C.free(unsafe.Pointer(label)) + } + buttons := []string{"textBrailleNoneButton", "textBraille7Button", "textBraille8Button", "textBrailleBothButton"} + index := 0 + switch settings.Indicator { + case 0x40: + index = 1 + case 0x80: + index = 2 + case 0xc0: + index = 3 + } + setCheck(buttons[index], true) +} + +func getTextAttributes() config.TextAttributeSettings { + settings := config.TextAttributeSettings{} + for row := 0; row < int(C.prefs_text_attribute_count()); row++ { + key := C.prefs_text_attribute_key(C.int(row)) + choice := config.TextAttributeChoice{Key: C.GoString(key), + Speak: C.prefs_text_attribute_enabled(C.int(row), 2) != 0, + Braille: C.prefs_text_attribute_enabled(C.int(row), 3) != 0} + C.g_free(C.gpointer(key)) + settings.Choices = append(settings.Choices, choice) + } + switch { + case getCheck("textBraille7Button"): + settings.Indicator = 0x40 + case getCheck("textBraille8Button"): + settings.Indicator = 0x80 + case getCheck("textBrailleBothButton"): + settings.Indicator = 0xc0 + } + return settings +} diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go new file mode 100644 index 0000000..a3b4578 --- /dev/null +++ b/internal/preferences/ui.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#cgo pkg-config: gtk+-3.0 +#include +#include "native.h" +*/ +import "C" + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "io" + "runtime" + "time" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +//go:embed preferences.ui +var layout string + +// Run owns all GTK calls on one OS thread. Its caller supplies private pipes; +// closing the parent pipe closes the window. No widget invokes speech directly. +func Run(ctx context.Context, input io.Reader, output io.Writer) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + updates := make(chan Response, 1) + errorsIn := make(chan error, 1) + go read_responses(ctx, input, updates, errorsIn) + var initial Response + select { + case initial = <-updates: + case err := <-errorsIn: + return err + case <-ctx.Done(): + return ctx.Err() + } + if initial.ChooseApplication { + response, chosen, err := choose_application(ctx, updates, errorsIn, output) + if err != nil || !chosen { + return err + } + initial = response + } + xml := C.CString(layout) + defer C.free(unsafe.Pointer(xml)) + if C.prefs_init(xml) == 0 { + return errors.New("cannot open GTK preferences on this display") + } + defer C.prefs_close() + if initial.Application != "" { + name := C.CString(initial.Application) + C.prefs_set_application(name) + C.free(unsafe.Pointer(name)) + } + session := &uiSession{catalog: initial.Catalog, soundThemes: []string{"default", "none"}} + session.setValues(initial.Values) + tentacles := &tentaclePage{} + tentacles.populate(initial.Tentacles) + var tentaclePending *tentacle.ManagementRequest + C.prefs_voice_changed() + status(initial.Message, false) + canSave, busy := initial.CanSave, false + previewPending, previewDirty := false, false + C.prefs_busy(0, boolean(canSave)) + encoder := json.NewEncoder(output) + // AT-SPI calls into this GTK process are served by the same event pump. + // Focus snapshots make several synchronous calls, so a 10 ms poll adds + // noticeable delay to every Tab stop in the dialog. + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errorsIn: + return err + case response := <-updates: + if response.Present { + C.prefs_present() + continue + } + if response.Preview { + previewPending = false + if !response.Accepted { + status(response.Message, true) + } + continue + } + if response.Close { + return nil + } + if tentaclePending != nil { + settingsOpened := tentaclePending.Action == "settings" && response.Accepted + // Management commits independently of the ordinary settings draft. + // Its response does not contain Values or CanSave. + if response.Tentacles != nil { + tentacles.populate(response.Tentacles) + if response.Accepted { + tentacles.accepted(tentaclePending) + } + } + tentaclePending, busy = nil, false + C.prefs_busy(0, boolean(canSave)) + if !settingsOpened { + status(response.Message, true) + } + continue + } + busy, canSave = false, response.CanSave + if response.Accepted { + session.setValues(response.Values) + C.prefs_voice_changed() + } + C.prefs_busy(0, boolean(canSave)) + status(response.Message, !response.Accepted) + case <-ticker.C: + C.prefs_iterate() + session.refreshEchoVoices() + session.refreshSpeechVoices() + session.refreshKeyBindingLayout() + action := C.prefs_action() + if action >= 9 && action <= 20 && !busy { + request, err := tentacles.action(int(action)) + if err != nil { + status(err.Error(), true) + continue + } + if request != nil { + if err := encoder.Encode(Request{Tentacle: request}); err != nil { + return err + } + tentaclePending, busy = request, true + // Keep the focused Settings button enabled while the internal + // form takes keyboard ownership without moving native focus. + if request.Action != "settings" { + C.prefs_busy(1, boolean(canSave)) + } + if request.Action == "install" || request.Action == "install-enable" { + status("Downloading and checking the tentacle package. Close preferences to cancel before it is saved.", true) + } + } + continue + } + if action == 8 { + setTextAttributes(config.DefaultTextAttributes()) + continue + } + if action >= 4 && action <= 7 { + session.editKeyBinding(int(action)) + continue + } + if action == 3 { + // The parent restores any active preview when this helper exits. + return nil + } + if C.prefs_voice_changed() != 0 { + previewDirty = true + } + if (action == 1 || action == 2) && !busy { + values, err := session.getValues() + if err != nil { + status(err.Error(), true) + continue + } + if err := encoder.Encode(Request{Values: values, Save: canSave, Close: action == 2}); err != nil { + return err + } + busy = true + previewDirty = false + C.prefs_busy(1, boolean(canSave)) + continue + } + if !previewDirty || previewPending || busy { + continue + } + values, err := session.getValues() + if err != nil { + status(err.Error(), true) + previewDirty = false + continue + } + if err := encoder.Encode(Request{Values: values, Preview: true}); err != nil { + return err + } + previewPending = true + previewDirty = false + } + } +} diff --git a/internal/preferences/ui_values.go b/internal/preferences/ui_values.go new file mode 100644 index 0000000..2eb3393 --- /dev/null +++ b/internal/preferences/ui_values.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +func (session *uiSession) setValues(values Values) { + setPronunciations(values.Pronunciations) + setTextAttributes(values.TextAttributes) + setOCRValues(values.OCR) + session.set_general_values(values) + set_sound_checks(values) + set_speech_support(values) + session.set_braille_values(values) + session.set_sound_values(values) + session.setEchoVoice(values) + session.setSpeechVoice(values) + set_echo_checks(values) + set_speech_presentation(values) +} + +func (session *uiSession) getValues() (Values, error) { + var values Values + var err error + if values.Pronunciations, err = getPronunciations(); err != nil { + return values, err + } + values.TextAttributes = getTextAttributes() + values.OCR = getOCRValues() + if err := session.get_general_values(&values); err != nil { + return values, err + } + get_sound_checks(&values) + get_speech_support(&values) + if err := session.get_braille_values(&values); err != nil { + return values, err + } + if err := session.get_sound_values(&values); err != nil { + return values, err + } + get_echo_checks(&values) + getReading(&values) + if err := session.get_speech_values(&values); err != nil { + return values, err + } + return values, nil +} diff --git a/internal/preferences/values.go b/internal/preferences/values.go new file mode 100644 index 0000000..10839d8 --- /dev/null +++ b/internal/preferences/values.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package preferences implements the isolated GTK preferences process and its +// bounded, typed protocol. The UI never opens settings files or speech sockets. +package preferences + +import ( + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func FromSettings(settings config.Settings) Values { + return Values{OCR: settings.OCR, Pronunciations: append([]config.PronunciationEntry(nil), settings.Pronunciations...), + TextAttributes: config.TextAttributeSettings{Choices: append([]config.TextAttributeChoice(nil), settings.TextAttributes.Choices...), Indicator: settings.TextAttributes.Indicator}, + KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...), + KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat, + EnableSound: settings.EnableSound, EnableBraille: settings.EnableBraille, + EnableSpeech: settings.EnableSpeech, + SpeechHistoryLimit: settings.SpeechHistoryLimit, + SpeechVerbosityLevel: settings.SpeechVerbosityLevel, + SayAllStyle: settings.SayAllStyle, + EnableContractedBraille: settings.EnableContractedBraille, BrailleContractionTable: settings.BrailleContractionTable, + Braille: settings.Braille, + SoundVolume: settings.SoundVolume, SoundSink: settings.SoundSink, + SoundTheme: settings.SoundTheme, PlaySoundForRole: settings.PlaySoundForRole, + PlaySoundForState: settings.PlaySoundForState, PlaySoundForPositionInSet: settings.PlaySoundForPositionInSet, + SpatializeObjectSounds: settings.SpatializeObjectSounds, + ProgressBar: settings.ProgressBar, + RoleSoundPresentation: settings.RoleSoundPresentation, + Rate: settings.DefaultVoice.Rate, Pitch: settings.DefaultVoice.AveragePitch, + Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle, Capitalization: settings.CapitalizationStyle, + SpeechOutputModule: settings.SpeechOutputModule, + SpeechServerFactory: settings.SpeechServerFactory, + HardwareSpeechDevice: settings.HardwareSpeechDevice, + HardwareSpeechBaudRate: settings.HardwareSpeechBaudRate, + SpeechVoiceFamily: settings.DefaultVoice.Family, + UppercaseVoice: settings.UppercaseVoice, + HyperlinkVoice: settings.HyperlinkVoice, + SystemVoice: settings.SystemVoice, + Reading: settings.Reading, + SpeakDescription: settings.SpeakDescription, + EnableKeyEcho: settings.EnableKeyEcho, + EnableAlphabeticKeys: settings.EnableAlphabeticKeys, + EnableNumericKeys: settings.EnableNumericKeys, + EnablePunctuationKeys: settings.EnablePunctuationKeys, + EnableSpace: settings.EnableSpace, + EnableModifierKeys: settings.EnableModifierKeys, + EnableFunctionKeys: settings.EnableFunctionKeys, + EnableActionKeys: settings.EnableActionKeys, + EnableNavigationKeys: settings.EnableNavigationKeys, + EnableDiacriticalKeys: settings.EnableDiacriticalKeys, + EnableEchoByCharacter: settings.EnableEchoByCharacter, + EnableEchoByWord: settings.EnableEchoByWord, + EnableEchoBySentence: settings.EnableEchoBySentence, + UseCustomEchoVoice: settings.UseCustomEchoVoice, + UseCustomEchoSpeechServer: settings.UseCustomEchoSpeechServer, + UseCustomEchoForKey: settings.UseCustomEchoForKey, + UseCustomEchoForCharacter: settings.UseCustomEchoForCharacter, + UseCustomEchoForWord: settings.UseCustomEchoForWord, + UseCustomEchoForSentence: settings.UseCustomEchoForSentence, + EchoVoice: settings.EchoVoice, EchoSpeechServer: settings.EchoSpeechServer, + } +} + +func (values Values) Merge(settings config.Settings) (config.Settings, error) { + settings.Pronunciations = append([]config.PronunciationEntry(nil), values.Pronunciations...) + settings.TextAttributes = config.TextAttributeSettings{Choices: append([]config.TextAttributeChoice(nil), values.TextAttributes.Choices...), Indicator: values.TextAttributes.Indicator} + settings.OCR = values.OCR + settings.KeyboardLayout = values.KeyboardLayout + settings.CthulhuModifierKeys = append([]string(nil), values.CthulhuModifierKeys...) + settings.KeyBindings = input.CloneBindingOverrides(values.KeyBindings) + settings.TimeFormat = values.TimeFormat + settings.DateFormat = values.DateFormat + settings.EnableSound = values.EnableSound + settings.EnableSpeech = values.EnableSpeech + settings.SpeechHistoryLimit = values.SpeechHistoryLimit + settings.SpeechVerbosityLevel = values.SpeechVerbosityLevel + settings.SayAllStyle = values.SayAllStyle + settings.EnableBraille = values.EnableBraille + settings.EnableContractedBraille = values.EnableContractedBraille + settings.BrailleContractionTable = values.BrailleContractionTable + settings.Braille = values.Braille + settings.SoundVolume = values.SoundVolume + settings.SoundSink = values.SoundSink + settings.SoundTheme = values.SoundTheme + settings.PlaySoundForRole = values.PlaySoundForRole + settings.PlaySoundForState = values.PlaySoundForState + settings.PlaySoundForPositionInSet = values.PlaySoundForPositionInSet + settings.SpatializeObjectSounds = values.SpatializeObjectSounds + settings.ProgressBar = values.ProgressBar + settings.RoleSoundPresentation = values.RoleSoundPresentation + settings.EnableKeyEcho = values.EnableKeyEcho + settings.EnableAlphabeticKeys = values.EnableAlphabeticKeys + settings.EnableNumericKeys = values.EnableNumericKeys + settings.EnablePunctuationKeys = values.EnablePunctuationKeys + settings.EnableSpace = values.EnableSpace + settings.EnableModifierKeys = values.EnableModifierKeys + settings.EnableFunctionKeys = values.EnableFunctionKeys + settings.EnableActionKeys = values.EnableActionKeys + settings.EnableNavigationKeys = values.EnableNavigationKeys + settings.EnableDiacriticalKeys = values.EnableDiacriticalKeys + settings.EnableEchoByCharacter = values.EnableEchoByCharacter + settings.EnableEchoByWord = values.EnableEchoByWord + settings.EnableEchoBySentence = values.EnableEchoBySentence + settings.UseCustomEchoVoice = values.UseCustomEchoVoice + settings.UseCustomEchoSpeechServer = values.UseCustomEchoSpeechServer + settings.UseCustomEchoForKey = values.UseCustomEchoForKey + settings.UseCustomEchoForCharacter = values.UseCustomEchoForCharacter + settings.UseCustomEchoForWord = values.UseCustomEchoForWord + settings.UseCustomEchoForSentence = values.UseCustomEchoForSentence + settings.EchoVoice = values.EchoVoice + settings.EchoSpeechServer = values.EchoSpeechServer + settings.DefaultVoice.Rate = values.Rate + settings.DefaultVoice.AveragePitch = values.Pitch + settings.DefaultVoice.Gain = values.Volume + settings.SpeechOutputModule = values.SpeechOutputModule + settings.SpeechServerFactory = values.SpeechServerFactory + settings.HardwareSpeechDevice = values.HardwareSpeechDevice + settings.HardwareSpeechBaudRate = values.HardwareSpeechBaudRate + settings.DefaultVoice.Family = values.SpeechVoiceFamily + settings.UppercaseVoice = values.UppercaseVoice + settings.HyperlinkVoice = values.HyperlinkVoice + settings.SystemVoice = values.SystemVoice + settings.VerbalizePunctuationStyle = values.Punctuation + settings.CapitalizationStyle = values.Capitalization + settings.Reading = values.Reading + settings.SpeakDescription = values.SpeakDescription + return settings, config.Validate(settings) +} diff --git a/internal/preferences/widgets.go b/internal/preferences/widgets.go new file mode 100644 index 0000000..5df5a9a --- /dev/null +++ b/internal/preferences/widgets.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "unsafe" +) + +func boolean(value bool) C.int { + if value { + return 1 + } + return 0 +} + +func status(message string, focus bool) { + text := C.CString(message) + defer C.free(unsafe.Pointer(text)) + C.prefs_status(text, boolean(focus)) +} + +func setChoice(name string, value int) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_choice_set(id, C.int(value)) +} + +func getChoice(name string) int { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + return int(C.prefs_choice_get(id)) +} + +func setCombo(name string, value int) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_combo_set(id, C.int(value)) +} + +func getCombo(name string) int { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + return int(C.prefs_combo_get(id)) +} + +func setCheck(name string, value bool) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_check_set(id, boolean(value)) +} + +func getCheck(name string) bool { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + return C.prefs_check_get(id) != 0 +} + +func setRange(name string, value float64) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_range_set(id, C.double(value)) +} + +func getRange(name string) float64 { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + return float64(C.prefs_number_value(id)) +} + +func setSpin(name string, value float64) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_spin_set(id, C.double(value)) +} + +func getSpin(name string) float64 { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + return float64(C.prefs_spin_get(id)) +} + +func comboItems(name string, labels []string, selected int) { + id := C.CString(name) + defer C.free(unsafe.Pointer(id)) + C.prefs_combo_clear(id) + for _, label := range labels { + text := C.CString(label) + C.prefs_combo_append(id, text) + C.free(unsafe.Pointer(text)) + } + C.prefs_combo_set(id, C.int(selected)) +} diff --git a/internal/presentation/control_values.go b/internal/presentation/control_values.go new file mode 100644 index 0000000..319f13b --- /dev/null +++ b/internal/presentation/control_values.go @@ -0,0 +1,203 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "fmt" + "math" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// ComboBoxSelectionText presents the current choice without repeating the +// control's label or role. Native combo snapshots prefer selected-child text. +func ComboBoxSelectionText(snapshot atspi.Snapshot) string { + if value := controlValue(snapshot); value != "" { + return value + } + if snapshot.Text != "" { + return snapshot.Text + } + return snapshot.Name +} + +func controlValue(snapshot atspi.Snapshot) string { + switch snapshot.Role { + case "combo box": + if snapshot.ValueText != "" { + return snapshot.ValueText + } + return snapshot.Description + case "spin button": + if snapshot.Text != "" { + return snapshot.Text + } + if snapshot.ValueText != "" { + return snapshot.ValueText + } + if !snapshot.ValueKnown { + return snapshot.Text + } + case "dial", "progress bar", "scroll bar", "slider", "split pane": + default: + return "" + } + if snapshot.ValueText != "" { + return snapshot.ValueText + } + if !snapshot.ValueKnown || math.IsNaN(snapshot.CurrentValue) || math.IsInf(snapshot.CurrentValue, 0) { + return "" + } + if snapshot.Role != "progress bar" { + // Python's textForValue formats the actual value using the provider's + // minimum increment, rather than normalizing controls to percentages. + increment := snapshot.MinimumIncrement + if increment < 0 || math.IsNaN(increment) || math.IsInf(increment, 0) { + return "" + } + places := 0 + if increment > 0 { + places = int(math.Ceil(math.Max(0, -math.Log10(increment)))) + } else if math.Abs(snapshot.CurrentValue) < 1 { + places = 1 + } + return fmt.Sprintf("%.*f", places, snapshot.CurrentValue) + } + if snapshot.MaximumValue == snapshot.MinimumValue { + return "" + } + percentage := math.Round((snapshot.CurrentValue - snapshot.MinimumValue) / + (snapshot.MaximumValue - snapshot.MinimumValue) * 100) + return fmt.Sprintf("%.0f percent", percentage) +} + +func controlStates(snapshot atspi.Snapshot) []string { + var states []string + switch snapshot.Role { + case "check box", "check menu item": + switch { + case snapshot.Indeterminate: + states = append(states, "partially checked") + case snapshot.Checked: + states = append(states, "checked") + default: + states = append(states, "not checked") + } + case "radio button", "radio menu item": + if snapshot.Checked { + states = append(states, "selected") + } else { + states = append(states, "not selected") + } + case "switch": + if snapshot.Checked { + states = append(states, "on") + } else { + states = append(states, "off") + } + case "toggle button": + if snapshot.Checked { + states = append(states, "pressed") + } else { + states = append(states, "not pressed") + } + } + if snapshot.Expandable { + if snapshot.Expanded { + states = append(states, "expanded") + } else { + states = append(states, "collapsed") + } + } + if snapshot.Selected && presentsSelectedState(snapshot.Role) { + states = append(states, "selected") + } + if snapshot.Multiselectable { + states = append(states, "multi-select") + } + if snapshot.ReadOnly { + states = append(states, "read only") + } + if snapshot.Required { + states = append(states, "required") + } + if snapshot.Invalid { + states = append(states, "invalid entry") + } + if snapshot.SensitiveKnown && !snapshot.Sensitive { + states = append(states, "grayed") + } + return states +} + +func presentsSelectedState(role string) bool { + switch role { + case "icon", "list item", "tree item": + return true + default: + return false + } +} + +func stateChangeText(snapshot atspi.Snapshot, eventType string) string { + switch eventType { + case "object:state-changed:checked", "object:state-changed:indeterminate", "object:state-changed:pressed": + for _, state := range controlStates(snapshot) { + switch state { + case "partially checked", "checked", "not checked", "selected", "not selected", "on", "off", "pressed", "not pressed": + return state + } + } + case "object:state-changed:expanded": + if snapshot.Expanded { + return "expanded" + } + return "collapsed" + case "object:state-changed:invalid-entry": + if snapshot.Invalid { + return "invalid entry" + } + case "object:state-changed:read-only": + if snapshot.ReadOnly { + return "read only" + } + return "editable" + case "object:state-changed:required": + if snapshot.Required { + return "required" + } + case "object:state-changed:selected": + if snapshot.Role == "table cell" { + if snapshot.Selected { + return "selected" + } + return "" + } + if presentsSelectedState(snapshot.Role) { + if snapshot.Selected { + return "selected" + } + return "not selected" + } + case "object:state-changed:sensitive": + if snapshot.SensitiveKnown && !snapshot.Sensitive { + return "grayed" + } + } + return "" +} diff --git a/internal/presentation/control_values_test.go b/internal/presentation/control_values_test.go new file mode 100644 index 0000000..162cc12 --- /dev/null +++ b/internal/presentation/control_values_test.go @@ -0,0 +1,205 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestFocusTextPresentsControlStates(t *testing.T) { + tests := []struct { + name string + snapshot atspi.Snapshot + want string + }{ + {name: "unchecked checkbox", snapshot: atspi.Snapshot{Name: "Updates", Role: "check box"}, want: "Updates, check box, not checked"}, + {name: "checked checkbox", snapshot: atspi.Snapshot{Name: "Updates", Role: "check box", Checked: true}, want: "Updates, check box, checked"}, + {name: "mixed checkbox", snapshot: atspi.Snapshot{Name: "Updates", Role: "check box", Indeterminate: true}, want: "Updates, check box, partially checked"}, + {name: "selected radio", snapshot: atspi.Snapshot{Name: "Automatic", Role: "radio button", Checked: true}, want: "Automatic, radio button, selected"}, + {name: "radio group", snapshot: atspi.Snapshot{Name: "Some", Role: "radio button", RadioGroupName: "Punctuation Level", Checked: true}, want: "Punctuation Level, Some, radio button, selected"}, + {name: "switch off", snapshot: atspi.Snapshot{Name: "Network", Role: "switch"}, want: "Network, switch, off"}, + {name: "toggle pressed", snapshot: atspi.Snapshot{Name: "Bold", Role: "toggle button", Checked: true}, want: "Bold, toggle button, pressed"}, + {name: "collapsed item", snapshot: atspi.Snapshot{Name: "Documents", Role: "tree item", Expandable: true}, want: "Documents, tree item, collapsed"}, + {name: "expanded selected item", snapshot: atspi.Snapshot{Name: "Documents", Role: "tree item", Expandable: true, Expanded: true, Selected: true}, want: "Documents, tree item, expanded, selected"}, + {name: "selected menu item", snapshot: atspi.Snapshot{Name: "Open", Role: "menu item", Selected: true}, want: "Open, menu item"}, + {name: "selected table cell", snapshot: atspi.Snapshot{Name: "First post", Role: "table cell", Selected: true}, want: "First post"}, + {name: "field requirements", snapshot: atspi.Snapshot{Name: "Account", Role: "text", ReadOnly: true, Required: true, Invalid: true}, want: "Account, text, read only, required, invalid entry"}, + {name: "disabled control", snapshot: atspi.Snapshot{Name: "Apply", Role: "push button", SensitiveKnown: true}, want: "Apply, push button, grayed"}, + {name: "unknown sensitivity", snapshot: atspi.Snapshot{Name: "Apply", Role: "push button"}, want: "Apply, push button"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := FocusText(test.snapshot); got != test.want { + t.Fatalf("FocusText() = %q, want %q", got, test.want) + } + }) + } +} + +func TestEventTextPresentsOnlyChangedState(t *testing.T) { + tests := []struct { + name string + event atspi.Event + want string + }{ + {name: "checked", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "check box", Checked: true}), want: "checked"}, + {name: "expanded", event: atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Name: "Files", Role: "tree item", Expanded: true}), want: "expanded"}, + {name: "menu selection omitted", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "Open", Role: "menu item", Selected: true}), want: ""}, + {name: "tree selection", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "Files", Role: "tree item", Selected: true}), want: "selected"}, + {name: "table cell selected", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "First post", Role: "table cell", Selected: true}), want: "selected"}, + {name: "table cell unselected", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "First post", Role: "table cell"}), want: ""}, + {name: "available omitted", event: atspi.NewEvent("object:state-changed:sensitive", atspi.Snapshot{Name: "Save", Role: "push button", SensitiveKnown: true, Sensitive: true}), want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := EventText(test.event); got != test.want { + t.Fatalf("EventText() = %q, want %q", got, test.want) + } + }) + } +} + +func TestEventTextPresentsValueWithoutRepeatingControl(t *testing.T) { + snapshot := atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, + CurrentValue: 42, MinimumValue: 0, MaximumValue: 100} + event := atspi.NewEvent(atspi.ValueChangedEvent, snapshot) + if got := EventText(event); got != "42" { + t.Fatalf("EventText() = %q, want %q", got, "42") + } +} + +func TestFocusTextPresentsControlValues(t *testing.T) { + tests := []struct { + name string + snapshot atspi.Snapshot + want string + }{ + { + name: "application value text", + snapshot: atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, + CurrentValue: 50, MinimumValue: 0, MaximumValue: 100, ValueText: "medium"}, + want: "Volume, slider, medium", + }, + { + name: "combo selected item", + snapshot: atspi.Snapshot{Name: "Time format", Role: "combo box", ValueText: "24-hour"}, + want: "Time format, combo box, 24-hour", + }, + { + name: "spin button actual value from nonzero range", + snapshot: atspi.Snapshot{Name: "Zoom", Role: "spin button", ValueKnown: true, + CurrentValue: 150, MinimumValue: 100, MaximumValue: 200}, + want: "Zoom, spin button, 150", + }, + { + name: "rounded percentage", + snapshot: atspi.Snapshot{Name: "Download", Role: "progress bar", ValueKnown: true, + CurrentValue: 1, MinimumValue: 0, MaximumValue: 3}, + want: "Download, progress bar, 33 percent", + }, + { + name: "unknown value", + snapshot: atspi.Snapshot{Name: "Volume", Role: "slider", + CurrentValue: 50, MinimumValue: 0, MaximumValue: 100}, + want: "Volume, slider", + }, + { + name: "actual value remains available for a fixed range", + snapshot: atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, + CurrentValue: 50, MinimumValue: 100, MaximumValue: 100}, + want: "Volume, slider, 50", + }, + { + name: "value excluded from unrelated role", + snapshot: atspi.Snapshot{Name: "Save", Role: "push button", ValueKnown: true, + CurrentValue: 50, MinimumValue: 0, MaximumValue: 100, ValueText: "medium"}, + want: "Save, push button", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := FocusText(test.snapshot); got != test.want { + t.Fatalf("FocusText() = %q, want %q", got, test.want) + } + }) + } +} + +func TestNumericValueUsesProviderPrecision(t *testing.T) { + for _, test := range []struct { + name string + value, increment float64 + want string + }{ + {"integer", 150, 1, "150"}, + {"hundredths", 1.25, 0.01, "1.25"}, + {"quarter step", 1.25, 0.25, "1.2"}, + {"zero increment fractional", 0.5, 0, "0.5"}, + {"negative fractional", -0.5, 0, "-0.5"}, + {"invalid increment", 1, -1, ""}, + } { + t.Run(test.name, func(t *testing.T) { + for _, role := range []string{"slider", "spin button", "dial", "scroll bar", "split pane"} { + snapshot := atspi.Snapshot{Role: role, ValueKnown: true, CurrentValue: test.value, + MinimumIncrement: test.increment, MinimumValue: -100, MaximumValue: 200} + if got := EventText(atspi.NewEvent(atspi.ValueChangedEvent, snapshot)); got != test.want { + t.Fatalf("%s: value = %q, want %q", role, got, test.want) + } + } + }) + } +} + +func TestSliderFocusUsesCurrentValueInsteadOfStaleText(t *testing.T) { + for _, tc := range []struct { + name, text, valueText, description, want string + valueKnown bool + }{ + {"previous value", "4.0", "", "", "4.5", true}, + {"previous tenth", "4.4", "", "", "4.5", true}, + {"same value different formatting", "4.50", "", "", "4.5", true}, + {"provider value text", "4.0", "medium", "", "medium", true}, + {"description", "4.0", "", "Voice pitch", "4.5, Voice pitch", true}, + {"text without value", "4.0", "", "", "4.0", false}, + } { + t.Run(tc.name, func(t *testing.T) { + snapshot := atspi.Snapshot{Name: "Pitch:", Role: "slider", Text: tc.text, + ValueKnown: tc.valueKnown, CurrentValue: 4.5, MinimumIncrement: 0.1, + ValueText: tc.valueText, Description: tc.description} + if got := FocusText(snapshot); got != "Pitch:, slider, "+tc.want { + t.Fatalf("focus speech = %q", got) + } + options := DefaultOptions() + reading := reading_options(options) + reading.OnlySpeakDisplayedText = true + options.Reading = &reading + want := tc.want + if tc.description != "" { + want = "4.5" + } + if got := FocusTextWithOptions(snapshot, options); got != "Pitch: "+want { + t.Fatalf("displayed-only speech = %q", got) + } + }) + } +} diff --git a/internal/presentation/document_line.go b/internal/presentation/document_line.go new file mode 100644 index 0000000..11220a4 --- /dev/null +++ b/internal/presentation/document_line.go @@ -0,0 +1,127 @@ +package presentation + +import ( + "fmt" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// DocumentLineText mirrors the web script's content presentation: join the +// displayed text for each same-line object and speak common role/state details. +func DocumentLineText(line atspi.DocumentLine) string { + return DocumentLineTextWithOptions(line, DefaultOptions()) +} + +func DocumentLineTextWithOptions(line atspi.DocumentLine, options Options) string { + return DocumentLineSpeechWithOptions(line, options).Text +} + +func DocumentLineSpeechWithOptions(line atspi.DocumentLine, options Options) Speech { + reading := reading_options(options) + var parts []string + seen := make(map[atspi.ObjectID]bool) + type rowKey struct { + bus, path string + row int + } + rows := make(map[rowKey]bool) + for _, content := range line.Contents { + text := strings.TrimSpace(content.Text) + if content.Snapshot.Role == "table cell" && content.Snapshot.TableRowText != "" && FullRowEnabled(content.Snapshot, options) { + key := rowKey{content.Snapshot.BusName, content.Snapshot.TablePath, content.Snapshot.TableRowIndex} + if key.path != "" { + if rows[key] { + continue + } + rows[key] = true + } + text = content.Snapshot.TableRowText + } + if text == "" { + text = strings.TrimSpace(content.Snapshot.Name) + } + appendPart(&parts, text) + if reading.OnlySpeakDisplayedText { + continue + } + id := content.Snapshot.ID() + if seen[id] { + continue + } + _, roleCue := roleSoundCue(content.Snapshot, options) + _, stateCue := stateSoundCue(content.Snapshot, options) + role := lineRole(content.Snapshot) + if options.SpeechVerbosityLevel == 0 && (role == "icon" || role == "canvas") { + role = "" + } + if role != "" && !((roleCue || stateCue) && options.RoleSoundPresentation == "sound_only") { + appendPart(&parts, role) + } + states := controlStates(content.Snapshot) + if stateCue && options.RoleSoundPresentation == "sound_only" { + states = removeReplacedState(states) + } + for _, state := range states { + appendPart(&parts, state) + } + appendPart(&parts, controlValue(content.Snapshot)) + if reading.SpeakMisspelledIndicator && content.Snapshot.Misspelled { + appendPart(&parts, "misspelled") + } + seen[id] = true + } + if len(parts) == 0 { + if !options.SpeakBlankLines { + return Speech{} + } + return Speech{Text: "blank"} + } + return SpeechWithOptions(parts, options) +} + +// A text object can contribute several ranges to a visual line. Present its +// role and state sounds once, in document order. +func DocumentLineSoundCues(line atspi.DocumentLine, options Options) []sound.Cue { + var cues []sound.Cue + seen := make(map[atspi.ObjectID]bool) + for _, content := range line.Contents { + id := content.Snapshot.ID() + if !seen[id] { + cues = append(cues, SoundCues(content.Snapshot, options)...) + seen[id] = true + } + } + return cues +} + +func appendPart(parts *[]string, part string) { + if part == "" { + return + } + *parts = append(*parts, part) +} + +// Keep this deliberately small: line reading presents displayed text plus +// the role/state details Python's web generator keeps for common content. +func lineRole(snapshot atspi.Snapshot) string { + switch snapshot.Role { + case "heading": + if snapshot.HeadingLevel > 0 { + return fmt.Sprintf("heading level %d", snapshot.HeadingLevel) + } + return "heading" + case "link": + if snapshot.Visited { + return "visited link" + } + return "link" + case "footer", "form", "label", "menu item", "paragraph", "section", + "redundant object", "unknown", "table cell", "text", "static", + "content suggestion", "list item", "list", "document frame", + "document web", "alert", "row header", "toggle button": + return "" + } + return snapshot.Role +} diff --git a/internal/presentation/document_line_test.go b/internal/presentation/document_line_test.go new file mode 100644 index 0000000..50f79c2 --- /dev/null +++ b/internal/presentation/document_line_test.go @@ -0,0 +1,88 @@ +package presentation + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestDocumentLineText(t *testing.T) { + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/text", Role: "static"}, Text: "Read "}, + {Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/link", Role: "link"}, Text: "the link"}, + {Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/heading", Role: "heading", HeadingLevel: 2}, Text: ""}, + }} + if got := DocumentLineText(line); got != "Read, the link, link, heading level 2" { + t.Fatalf("DocumentLineText() = %q", got) + } + + line.Contents = line.Contents[:2] + line.Contents[1].Snapshot.Visited = true + if got := DocumentLineText(line); got != "Read, the link, visited link" { + t.Fatalf("visited line = %q", got) + } + + blank := atspi.DocumentLine{Contents: []atspi.DocumentContent{{ + Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/blank", Role: "static"}, Text: "\n", + }}} + if got := DocumentLineText(blank); got != "blank" { + t.Fatalf("blank line = %q", got) + } + withoutBlank := DefaultOptions() + withoutBlank.SpeakBlankLines = false + if got := DocumentLineTextWithOptions(blank, withoutBlank); got != "" { + t.Fatalf("disabled blank line = %q", got) + } + + listLine := atspi.DocumentLine{Contents: []atspi.DocumentContent{{ + Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/item", Role: "list item"}, Text: "List text", + }}} + if got := DocumentLineText(listLine); got != "List text" { + t.Fatalf("list item role = %q", got) + } + + controls := atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/save", Role: "push button"}, Text: "Save"}, + {Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/updates", Role: "check box", Checked: true}, Text: "Updates"}, + }} + if got := DocumentLineText(controls); got != "Save, push button, Updates, check box, checked" { + t.Fatalf("control line = %q", got) + } + + repeated := atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: atspi.Snapshot{BusName: ":1.1", Path: "/one", Role: "static"}, Text: "the"}, + {Snapshot: atspi.Snapshot{BusName: ":1.1", Path: "/two", Role: "static"}, Text: "the"}, + }} + if got := DocumentLineText(repeated); got != "the, the" { + t.Fatalf("repeated line content = %q", got) + } +} + +func TestDocumentLineSoundPolicy(t *testing.T) { + link := atspi.Snapshot{BusName: ":1.42", Path: "/link", Role: "link", Visited: true} + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: link, Text: "Read"}, {Snapshot: link, Text: "more"}, + {Snapshot: atspi.Snapshot{BusName: ":1.42", Path: "/check", Role: "check box", Checked: true}, Text: "Updates"}, + }} + options := DefaultOptions() + options.EnableSound, options.PlaySoundForRole, options.PlaySoundForState = true, true, true + options.RoleSoundPresentation = "sound_only" + cues := DocumentLineSoundCues(line, options) + if len(cues) != 2 || cues[0].Name != "link_visited" || cues[1].Name != "checkbox_checked" { + t.Fatalf("line cues = %#v", cues) + } + if got := DocumentLineTextWithOptions(line, options); got != "Read, more, Updates" { + t.Fatalf("sound-only text = %q", got) + } + options.PlaySoundForRole = false + if got := DocumentLineSoundCues(line, options); len(got) != 1 || got[0].Name != "checkbox_checked" { + t.Fatalf("state-only cues = %#v", got) + } + options.RoleSoundPresentation = "speech_only" + if got := DocumentLineSoundCues(line, options); len(got) != 0 { + t.Fatalf("speech-only cues = %#v", got) + } + if got := DocumentLineTextWithOptions(line, options); got != "Read, visited link, more, Updates, check box, checked" { + t.Fatalf("speech-only text = %q", got) + } +} diff --git a/internal/presentation/flat_review.go b/internal/presentation/flat_review.go new file mode 100644 index 0000000..91a85cd --- /dev/null +++ b/internal/presentation/flat_review.go @@ -0,0 +1,42 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import "strings" + +// FlatReviewText renders owned review text without altering the offsets in the +// navigation model. Single whitespace characters must be audible. +func FlatReviewText(text string, character bool) string { + if character { + switch text { + case " ": + return "space" + case "\t": + return "tab" + case "\u00a0": + return "no break space" + } + } + if strings.TrimSpace(text) == "" { + if !character && strings.Trim(text, "\r\n") != "" { + return "white space" + } + return "blank" + } + return text +} diff --git a/internal/presentation/flat_review_test.go b/internal/presentation/flat_review_test.go new file mode 100644 index 0000000..4cdf4f3 --- /dev/null +++ b/internal/presentation/flat_review_test.go @@ -0,0 +1,37 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import "testing" + +func TestFlatReviewWhitespaceAndExactText(t *testing.T) { + for _, tc := range []struct { + text string + character bool + want string + }{ + {" ", true, "space"}, {"\t", true, "tab"}, {"\u00a0", true, "no break space"}, + {" ", false, "white space"}, {"\t", false, "white space"}, + {"\n", false, "blank"}, {"\n", true, "blank"}, {"\r\n", false, "blank"}, + {"", true, "blank"}, {"é🙂", false, "é🙂"}, {"!", true, "!"}, + } { + if got := FlatReviewText(tc.text, tc.character); got != tc.want { + t.Fatalf("text=%q got=%q", tc.text, got) + } + } +} diff --git a/internal/presentation/position_sound_test.go b/internal/presentation/position_sound_test.go new file mode 100644 index 0000000..9d58ae7 --- /dev/null +++ b/internal/presentation/position_sound_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func TestPositionSoundPolicy(t *testing.T) { + base := Options{EnableSound: true, PlaySoundForPositionInSet: true, RoleSoundPresentation: "sound_and_speech"} + for _, test := range []struct { + name string + position, size int + want string + }{ + {"first", 1, 3, sound.PositionCueName(0)}, + {"middle", 2, 3, sound.PositionCueName(0.5)}, + {"last", 3, 3, sound.PositionCueName(1)}, + {"virtual set", 501, 1001, sound.PositionCueName(0.5)}, + {"unknown", 0, 0, ""}, {"negative", -1, 3, ""}, + {"outside set", 4, 3, ""}, {"unknown size", 1, -1, ""}, + {"single item", 1, 1, ""}, + } { + t.Run(test.name, func(t *testing.T) { + snapshot := atspi.Snapshot{Name: "Example", Role: "list item", PositionInSet: test.position, SetSize: test.size} + cues := SoundCues(snapshot, base) + if test.want == "" { + if len(cues) != 0 { + t.Fatalf("invalid set produced cues: %v", cues) + } + } else if len(cues) != 1 || cues[0].Name != test.want { + t.Fatalf("position cues = %v, want %s", cues, test.want) + } + if got := FocusTextWithOptions(snapshot, base); got != "Example, list item" { + t.Fatalf("position cue changed speech: %q", got) + } + }) + } + snapshot := atspi.Snapshot{Role: "radio button", Checked: true, PositionInSet: 2, SetSize: 3} + for _, gate := range []string{"disabled sounds", "disabled position", "speech only", "unavailable", "defunct"} { + options, source := base, snapshot + switch gate { + case "disabled sounds": + options.EnableSound = false + case "disabled position": + options.PlaySoundForPositionInSet = false + case "speech only": + options.RoleSoundPresentation = "speech_only" + case "unavailable": + options.SoundCueAvailable = func(string) bool { return false } + case "defunct": + source.Defunct = true + } + if got := SoundCues(source, options); len(got) != 0 { + t.Errorf("%s cues: %v", gate, got) + } + } + base.PlaySoundForState = true + base.RoleSoundPresentation = "sound_only" + if got := SoundCues(snapshot, base); len(got) != 2 || got[0].Name != "radiobutton_checked" || got[1].Name != sound.PositionCueName(0.5) { + t.Fatalf("state/position order = %v", got) + } + if got := EventSoundCues(atspi.NewEvent("object:state-changed:checked", snapshot), base); len(got) != 1 || got[0].Name != "radiobutton_checked" { + t.Fatalf("state change replayed position: %v", got) + } + for _, eventType := range []string{atspi.FocusEvent, atspi.FocusedStateEvent, atspi.ActiveDescendantEvent, atspi.SelectionChangedEvent} { + if got := EventSoundCues(atspi.NewEvent(eventType, snapshot), base); len(got) != 2 { + t.Errorf("%s cues: %v", eventType, got) + } + } + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: snapshot}, {Snapshot: snapshot}}} + if got := DocumentLineSoundCues(line, base); len(got) != 2 { + t.Fatalf("line replayed position: %v", got) + } +} diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go new file mode 100644 index 0000000..4489cf7 --- /dev/null +++ b/internal/presentation/presentation.go @@ -0,0 +1,306 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +type Options struct { + Reading *config.ReadingSettings + EnableSound bool + PlaySoundForRole bool + PlaySoundForState bool + RoleSoundPresentation string + SpatializeObjectSounds bool + SoundCueAvailable func(string) bool + SpeakDescription bool + ReadFullRowInGUITable bool + SpeechVerbosityLevel int + SpeakBlankLines bool + SayAllStyle string + EnableKeyEcho bool + EnableEchoByCharacter bool + EnableEchoByWord bool + EnableEchoBySentence bool + EnableAlphabeticKeys bool + EnableNumericKeys bool + EnablePunctuationKeys bool + EnableSpace bool + EnableDiacriticalKeys bool + + PlaySoundForPositionInSet bool +} + +func DefaultOptions() Options { + return Options{ + SpeakDescription: true, + ReadFullRowInGUITable: true, + SpeechVerbosityLevel: 1, + SpeakBlankLines: true, + SayAllStyle: "line", + EnableKeyEcho: true, + EnableAlphabeticKeys: true, + EnableNumericKeys: true, + EnablePunctuationKeys: true, + EnableSpace: true, + } +} + +func FocusText(snapshot atspi.Snapshot) string { + return FocusTextWithOptions(snapshot, DefaultOptions()) +} + +func FocusTextWithOptions(snapshot atspi.Snapshot, options Options) string { + return FocusSpeechWithOptions(snapshot, options).Text +} + +func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech { + // GTK sliders can expose the previous displayed number through Text + // while Value already contains the current number. Present Value once. + if snapshot.Role == "slider" && controlValue(snapshot) != "" { + snapshot.Text = "" + } + reading := reading_options(options) + var parts []string + role := snapshot.Role + if role == "entry" && snapshot.Autocomplete { + role = "autocomplete" + } + if role == "heading" && snapshot.HeadingLevel > 0 { + role = fmt.Sprintf("heading level %d", snapshot.HeadingLevel) + } + if role == "link" && snapshot.Visited { + role = "visited link" + } + if role == "table cell" { + role = "" + if FullRowEnabled(snapshot, options) && snapshot.TableRowText != "" { + if snapshot.Text == snapshot.Name { + snapshot.Text = "" + } + snapshot.Name = snapshot.TableRowText + } + } + if reading.OnlySpeakDisplayedText { + return SpeechWithOptions([]string{displayed_focus_text(snapshot)}, options) + } + if role == "table" && snapshot.TableRowCount > 0 && snapshot.TableColumnCount > 0 { + if options.SpeechVerbosityLevel == 0 { + role = "table" + } else { + rowWord, columnWord := "rows", "columns" + if snapshot.TableRowCount == 1 { + rowWord = "row" + } + if snapshot.TableColumnCount == 1 { + columnWord = "column" + } + role = fmt.Sprintf("table with %d %s %d %s", snapshot.TableRowCount, rowWord, + snapshot.TableColumnCount, columnWord) + } + } + if options.SpeechVerbosityLevel == 0 && (role == "icon" || role == "canvas") { + role = "" + } + _, replaceRole := roleSoundCue(snapshot, options) + if options.RoleSoundPresentation == "sound_only" && replaceRole { + role = "" + } + if options.SpeechVerbosityLevel != 0 && snapshot.ChildCountKnown && snapshot.ChildCount > 0 { + switch snapshot.Role { + case "list", "list box": + role = itemCountText(snapshot.ChildCount) + } + } + fields := []string{} + if snapshot.Role == "radio button" && snapshot.RadioGroupName != snapshot.Name { + fields = append(fields, snapshot.RadioGroupName) + } + if snapshot.Role == "table cell" { + fields = append(fields, + TableHeaderText(snapshot.TableRowHeaders, "row header", options.SpeechVerbosityLevel), + TableHeaderText(snapshot.TableColumnHeaders, "column header", options.SpeechVerbosityLevel)) + } + fields = append(fields, snapshot.Name, role) + states := controlStates(snapshot) + _, replaceState := stateSoundCue(snapshot, options) + if options.RoleSoundPresentation == "sound_only" && replaceState { + states = removeReplacedState(states) + } + fields = append(fields, states...) + fields = append(fields, controlValue(snapshot)) + if options.SpeakDescription { + fields = append(fields, snapshot.Description) + } + if snapshot.Role != "spin button" { + fields = append(fields, snapshot.Text) + } + if snapshot.Autocomplete && snapshot.SelectionEnd > snapshot.SelectionStart { + fields = append(fields, "selected") + } + if reading.EnablePositionSpeaking && snapshot.PositionInSet > 0 && snapshot.SetSize >= snapshot.PositionInSet { + fields = append(fields, fmt.Sprintf("%d of %d", snapshot.PositionInSet, snapshot.SetSize)) + } + if reading.EnableMnemonicSpeaking && snapshot.Mnemonic != "" { + fields = append(fields, snapshot.Mnemonic) + } + if reading.EnableTutorialMessages { + fields = append(fields, control_tutorial(snapshot)) + } + if reading.SpeakMisspelledIndicator && snapshot.Misspelled { + fields = append(fields, "misspelled") + } + for _, part := range fields { + if part != "" && !contains(parts, part) { + parts = append(parts, part) + } + } + return SpeechWithOptions(parts, options) +} + +func removeReplacedState(states []string) []string { + for index, state := range states { + switch state { + case "partially checked", "checked", "not checked", "selected", "not selected", "on", "off", "pressed", "not pressed": + return append(states[:index:index], states[index+1:]...) + } + } + return states +} + +func contains(values []string, candidate string) bool { + for _, value := range values { + if value == candidate { + return true + } + } + return false +} + +func EventText(event atspi.Event) string { + return EventTextWithOptions(event, DefaultOptions()) +} + +func EventTextWithOptions(event atspi.Event, options Options) string { + return EventSpeechWithOptions(event, options).Text +} + +func EventSpeechWithOptions(event atspi.Event, options Options) Speech { + switch event.Type { + case atspi.WindowActivatedEvent: + speech := FocusSpeechWithOptions(event.Source, options) + focus := speech.Text + if event.Source.Role == "table cell" && event.Source.TableName != "" { + table := atspi.Snapshot{Role: "table", TableRowCount: event.Source.TableRowCount, + TableColumnCount: event.Source.TableColumnCount} + speech = speech.Prepend(FocusTextWithOptions(table, options), ", ") + speech = speech.Prepend(event.Source.TableName, ", ") + focus = speech.Text + } + title := strings.TrimSpace(event.WindowTitle) + if options.SpeakDescription && event.WindowDescription != "" && event.WindowDescription != event.Source.Description { + speech = speech.Append(event.WindowDescription, ", ") + focus = speech.Text + } + if title == "" || title == event.Source.Name { + return speech.WithText(focus) + } + if focus == "" { + return Speech{Text: title} + } + return speech.Prepend(title, ", ") + case atspi.DocumentLoadCompleteEvent: + if atspi.IsWebDocumentRole(event.Source.Role) { + return Speech{Text: strings.TrimSpace(event.Source.Name)} + } + return Speech{} + case atspi.CaretEvent: + if event.Source.CaretCharacter != "" { + return Speech{Text: caretCharacterText(event.Source.CaretCharacter)} + } + if event.Source.Text != "" { + return Speech{Text: event.Source.Text} + } + case atspi.TextInsertedEvent: + if !shouldEchoInsertion(event.Payload, options) { + return Speech{} + } + if event.Payload == " " { + return Speech{Text: "space"} + } + return Speech{Text: event.Payload} + case atspi.TextDeletedEvent: + if utf8.RuneCountInString(event.Payload) == 1 && unicode.IsSpace(firstRune(event.Payload)) { + return Speech{Text: caretCharacterText(event.Payload)} + } + return Speech{Text: event.Payload} + case atspi.ValueChangedEvent, atspi.AccessibleValueEvent: + return Speech{Text: controlValue(event.Source)} + case "object:state-changed:checked", + "object:state-changed:expanded", + "object:state-changed:indeterminate", + "object:state-changed:invalid-entry", + "object:state-changed:pressed", + "object:state-changed:read-only", + "object:state-changed:required", + "object:state-changed:selected", + "object:state-changed:sensitive": + if reading_options(options).OnlySpeakDisplayedText { + return Speech{} + } + text := stateChangeText(event.Source, event.Type) + if options.RoleSoundPresentation == "sound_only" && isReplaceableStateEvent(event.Type) { + if _, replaced := stateSoundCue(event.Source, options); replaced { + return Speech{} + } + } + return Speech{Text: text} + } + return FocusSpeechWithOptions(event.Source, options) +} + +func isReplaceableStateEvent(eventType string) bool { + switch eventType { + case "object:state-changed:checked", "object:state-changed:indeterminate", "object:state-changed:pressed": + return true + default: + return false + } +} + +func caretCharacterText(character string) string { + switch character { + case " ": + return "space" + case "\t": + return "tab" + case "\u00a0": + return "no break space" + } + if unicode.IsSpace(firstRune(character)) { + return "blank" + } + return character +} diff --git a/internal/presentation/presentation_test.go b/internal/presentation/presentation_test.go new file mode 100644 index 0000000..2a18429 --- /dev/null +++ b/internal/presentation/presentation_test.go @@ -0,0 +1,294 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestBriefSpeechReducesOptionalRoleDetail(t *testing.T) { + options := DefaultOptions() + table := atspi.Snapshot{Role: "table", TableRowCount: 2, TableColumnCount: 3} + if got := FocusTextWithOptions(table, options); got != "table with 2 rows 3 columns" { + t.Fatalf("verbose table = %q", got) + } + options.SpeechVerbosityLevel = 0 + if got := FocusTextWithOptions(table, options); got != "table" { + t.Fatalf("brief table = %q", got) + } + icon := atspi.Snapshot{Name: "Weather", Role: "icon"} + if got := FocusTextWithOptions(icon, options); got != "Weather" { + t.Fatalf("brief icon = %q", got) + } + list := atspi.Snapshot{Name: "Tasks", Role: "list", ChildCount: 3, ChildCountKnown: true} + if got := FocusTextWithOptions(list, options); got != "Tasks, list" { + t.Fatalf("brief list = %q", got) + } + options.SpeechVerbosityLevel = 1 + if got := FocusTextWithOptions(list, options); got != "Tasks, 3 items" { + t.Fatalf("verbose list = %q", got) + } + list.ChildCount = 1 + if got := FocusTextWithOptions(list, options); got != "Tasks, 1 item" { + t.Fatalf("singular list = %q", got) + } + list.ChildCountKnown = false + if got := FocusTextWithOptions(list, options); got != "Tasks, list" { + t.Fatalf("unknown list count = %q", got) + } + cell := atspi.Snapshot{Name: "42", Role: "table cell", TableRowHeaders: "Monday", TableColumnHeaders: "Total"} + if got := FocusTextWithOptions(cell, options); got != "Monday row header, Total column header, 42" { + t.Fatalf("verbose cell headers = %q", got) + } + options.SpeechVerbosityLevel = 0 + if got := FocusTextWithOptions(cell, options); got != "Monday, Total, 42" { + t.Fatalf("brief cell headers = %q", got) + } +} + +func TestFocusText(t *testing.T) { + tests := []struct { + name string + snapshot atspi.Snapshot + want string + }{ + {name: "all fields", snapshot: atspi.Snapshot{Name: "Save", Role: "push button"}, want: "Save, push button"}, + {name: "role only", snapshot: atspi.Snapshot{Role: "text frame"}, want: "text frame"}, + {name: "empty", snapshot: atspi.Snapshot{}, want: ""}, + {name: "description", snapshot: atspi.Snapshot{Name: "Example", Role: "dialog", Description: "Generic description"}, want: "Example, dialog, Generic description"}, + {name: "text", snapshot: atspi.Snapshot{Role: "terminal", Text: "Current terminal line"}, want: "terminal, Current terminal line"}, + {name: "selected autocomplete", snapshot: atspi.Snapshot{Name: "Address and search bar", Role: "entry", Autocomplete: true, Text: "https://example.test", SelectionEnd: 20}, want: "Address and search bar, autocomplete, https://example.test, selected"}, + {name: "table cell omits redundant role", snapshot: atspi.Snapshot{Name: "Budget", Role: "table cell"}, want: "Budget"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := FocusText(test.snapshot); got != test.want { + t.Fatalf("FocusText() = %q, want %q", got, test.want) + } + }) + } +} + +func TestFocusTextCanOmitDescription(t *testing.T) { + snapshot := atspi.Snapshot{Name: "Example", Role: "dialog", Description: "Generic description"} + options := Options{SpeakDescription: false} + if got := FocusTextWithOptions(snapshot, options); got != "Example, dialog" { + t.Fatalf("FocusTextWithOptions() = %q, want %q", got, "Example, dialog") + } + combo := atspi.Snapshot{Name: "Time format", Role: "combo box", ValueText: "24-hour"} + if got := FocusTextWithOptions(combo, options); got != "Time format, combo box, 24-hour" { + t.Fatalf("FocusTextWithOptions() combo = %q", got) + } + comboFromDescription := atspi.Snapshot{Name: "Time format", Role: "combo box", Description: "24-hour"} + if got := FocusTextWithOptions(comboFromDescription, options); got != "Time format, combo box, 24-hour" { + t.Fatalf("FocusTextWithOptions() combo description = %q", got) + } + spinFromText := atspi.Snapshot{Name: "Progress bar update frequency in seconds", Role: "spin button", Text: "10"} + if got := FocusTextWithOptions(spinFromText, options); got != "Progress bar update frequency in seconds, spin button, 10" { + t.Fatalf("FocusTextWithOptions() spin text = %q", got) + } + spinValueText := atspi.Snapshot{Name: "Zoom", Role: "spin button", Text: "150", ValueText: "150 percent"} + if got := FocusTextWithOptions(spinValueText, options); got != "Zoom, spin button, 150" { + t.Fatalf("FocusTextWithOptions() spin value text = %q", got) + } +} + +func TestEventTextUsesCaretLine(t *testing.T) { + snapshot := atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "Current terminal line", "", + ) + focus := atspi.NewEvent(atspi.CaretEvent, snapshot) + if got := EventText(focus); got != "Current terminal line" { + t.Fatalf("EventText() = %q, want caret line", got) + } +} + +func TestEventTextPrefersCaretCharacter(t *testing.T) { + snapshot := atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt ls", "l", + ) + event := atspi.NewEvent(atspi.CaretEvent, snapshot) + if got := EventText(event); got != "l" { + t.Fatalf("EventText() = %q, want %q", got, "l") + } +} + +func TestEventTextNamesWhitespaceCaretCharacters(t *testing.T) { + tests := []struct { + character string + want string + }{ + {character: " ", want: "space"}, + {character: "\t", want: "tab"}, + {character: "\u00a0", want: "no break space"}, + {character: "\u2003", want: "blank"}, + } + for _, test := range tests { + snapshot := atspi.NewSnapshotWithCaretCharacter( + "/accessible/terminal", "", "terminal", "", "prompt", test.character, + ) + event := atspi.NewEvent(atspi.CaretEvent, snapshot) + if got := EventText(event); got != test.want { + t.Fatalf("EventText(%q) = %q, want %q", test.character, got, test.want) + } + } +} + +func TestEventTextUsesChangedText(t *testing.T) { + tests := []struct { + name string + event atspi.Event + want string + fallback string + }{ + { + name: "insert", + event: atspi.NewEventWithPayload( + atspi.TextInsertedEvent, + atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt l"), + "l", + ), + want: "l", + }, + { + name: "delete", + event: atspi.NewEventWithPayload( + atspi.TextDeletedEvent, + atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt"), + "s", + ), + want: "s", + }, + { + name: "delete space", + event: atspi.NewEventWithPayload( + atspi.TextDeletedEvent, + atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt"), + " ", + ), + want: "space", + }, + { + name: "empty insert payload", + event: atspi.NewEventWithPayload( + atspi.TextInsertedEvent, + atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt"), + "", + ), + want: "", + fallback: "terminal, prompt", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := EventText(test.event); got != test.want { + t.Fatalf("EventText() = %q, want %q", got, test.want) + } + if test.fallback == "" { + return + } + if got := FocusText(test.event.Source); got != test.fallback { + t.Fatalf("FocusText() = %q, want %q", got, test.fallback) + } + }) + } +} + +func TestWebSemantics(t *testing.T) { + cases := []struct { + snapshot atspi.Snapshot + want string + }{ + {atspi.Snapshot{Name: "Section", Role: "heading", HeadingLevel: 2}, "Section, heading level 2"}, + {atspi.Snapshot{Name: "Section", Role: "heading"}, "Section, heading"}, + {atspi.Snapshot{Name: "Section", Role: "heading", HeadingLevel: -1}, "Section, heading"}, + {atspi.Snapshot{Name: "Custom", Role: "heading", HeadingLevel: 9}, "Custom, heading level 9"}, + {atspi.Snapshot{Name: "Example", Role: "link", Visited: true}, "Example, visited link"}, + {atspi.Snapshot{Name: "Example", Role: "link"}, "Example, link"}, + {atspi.Snapshot{Name: "Save", Role: "button", HeadingLevel: 2, Visited: true}, "Save, button"}, + } + for _, c := range cases { + if got := FocusText(c.snapshot); got != c.want { + t.Errorf("got %q; want %q", got, c.want) + } + if got := EventText(atspi.NewEvent("object:state-changed:focused", c.snapshot)); got != c.want { + t.Errorf("event got %q; want %q", got, c.want) + } + } +} + +func TestWindowActivationIncludesTopLevelTitle(t *testing.T) { + tests := []struct { + title, source, role, want string + }{ + {"Editor - Notes", "Save", "button", "Editor - Notes, Save, button"}, + {"Page title", "Page title", "document web", "Page title, document web"}, + {"", "Save", "button", "Save, button"}, + } + for _, test := range tests { + event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{Name: test.source, Role: test.role}) + event.WindowTitle = test.title + if got := EventText(event); got != test.want { + t.Errorf("title %q: got %q, want %q", test.title, got, test.want) + } + } +} + +func TestGUITablePresentation(t *testing.T) { + table := atspi.Snapshot{Name: "Desktop items", Role: "table", TableRowCount: 11, TableColumnCount: 2} + if got := FocusText(table); got != "Desktop items, table with 11 rows 2 columns" { + t.Fatalf("table focus = %q", got) + } + cell := atspi.Snapshot{Name: "Steam", Text: "Steam", Role: "table cell", TableName: "Desktop items", + TableRowCount: 11, TableColumnCount: 2, TableRowText: "Steam, Shortcut", GUITable: true} + if got := EventText(atspi.NewEvent(atspi.SelectionChangedEvent, cell)); got != "Steam, Shortcut" { + t.Fatalf("selected row = %q", got) + } + if got := EventTextWithOptions(atspi.NewEvent(atspi.SelectionChangedEvent, cell), + Options{ReadFullRowInGUITable: false}); got != "Steam" { + t.Fatalf("single cell setting = %q", got) + } + activation := atspi.NewEvent(atspi.WindowActivatedEvent, cell) + activation.WindowTitle = "I38 Desktop" + activation.WindowDescription = "Select a desktop item and press Enter to open it." + if got := EventText(activation); got != "I38 Desktop, Desktop items, table with 11 rows 2 columns, Steam, Shortcut, Select a desktop item and press Enter to open it." { + t.Fatalf("window activation = %q", got) + } + if got := EventTextWithOptions(activation, Options{SpeakDescription: false}); strings.Contains(got, "Select a desktop item") { + t.Fatalf("disabled description = %q", got) + } +} + +func TestDocumentLoadAnnouncesOnlyTitle(t *testing.T) { + for _, tc := range []struct{ role, name, want string }{ + {"document web", "Page title", "Page title"}, + {"document frame", "Page title", "Page title"}, + {"document web", "", ""}, + {"entry", "Address bar", ""}, + } { + snapshot := atspi.NewSnapshot("/doc", tc.name, tc.role, "Description", "Page contents") + if got := EventText(atspi.NewEvent(atspi.DocumentLoadCompleteEvent, snapshot)); got != tc.want { + t.Fatalf("%s: got %q, want %q", tc.role, got, tc.want) + } + } +} diff --git a/internal/presentation/reading.go b/internal/presentation/reading.go new file mode 100644 index 0000000..cfc9f14 --- /dev/null +++ b/internal/presentation/reading.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "fmt" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func reading_options(options Options) config.ReadingSettings { + if options.Reading != nil { + return *options.Reading + } + return config.DefaultSettings().Reading +} +func join_speech_parts(parts []string, options Options) string { + if !reading_options(options).EnablePauseBreaks { + return strings.Join(parts, " ") + } + return strings.Join(parts, ", ") +} +func displayed_focus_text(snapshot atspi.Snapshot) string { + var parts []string + for _, text := range []string{snapshot.Name, snapshot.Text, controlValue(snapshot)} { + if text != "" && !contains(parts, text) { + parts = append(parts, text) + } + } + return strings.Join(parts, " ") +} +func control_tutorial(snapshot atspi.Snapshot) string { + switch snapshot.Role { + case "push button", "button": + return "Press Space to activate." + case "check box", "toggle button": + return "Press Space to toggle." + case "radio button": + return "Use arrow keys to select an option." + case "combo box": + return "Press Alt+Down to open the list." + case "slider", "spin button": + return "Use arrow keys to change the value." + case "entry", "text": + if snapshot.Editable && !snapshot.ReadOnly { + return "Type to enter text." + } + case "list", "list box", "tree", "tree table": + return "Use arrow keys to move between items." + } + return "" +} + +func FullRowEnabled(snapshot atspi.Snapshot, options Options) bool { + reading := reading_options(options) + if snapshot.Spreadsheet { + return reading.ReadFullRowInSpreadSheet + } + if snapshot.GUITable { + return options.ReadFullRowInGUITable + } + return reading.ReadFullRowInDocumentTable +} + +// ContextType matches Python's separate container and landmark preferences. +func ContextType(snapshot atspi.Snapshot) string { + switch snapshot.Role { + case "block quote", "blockquote": + return "blockquote" + case "list", "list box": + return "list" + case "table", "tree table": + if !snapshot.LayoutTable { + return "table" + } + case "panel", "grouping": + return "panel" + case "landmark": + return "landmark" + case "form": + if snapshot.Name == "" { + return "form" + } + return "landmark" + } + for _, role := range strings.Fields(snapshot.XMLRoles) { + switch role { + case "banner", "complementary", "contentinfo", "main", "navigation", "search", "region": + return "landmark" + case "form": + if snapshot.Name != "" { + return "landmark" + } + return "form" + } + } + if strings.EqualFold(snapshot.HTMLTag, "blockquote") { + return "blockquote" + } + return "" +} + +func ContextEnabled(kind string, reading config.ReadingSettings) bool { + if reading.OnlySpeakDisplayedText { + return false + } + switch kind { + case "blockquote": + return reading.SpeakContextBlockquote + case "list": + return reading.SpeakContextList + case "table": + return reading.SpeakContextTable + case "panel": + return reading.SpeakContextPanel + case "landmark": + return reading.SpeakContextLandmark + case "form": + return reading.SpeakContextNonLandmarkForm + } + return false +} + +// Say All has its own context choices, independent of navigation speech. +func SayAllContextEnabled(kind string, reading config.ReadingSettings) bool { + if reading.OnlySpeakDisplayedText { + return false + } + switch kind { + case "blockquote": + return reading.SayAllContextBlockquote + case "list": + return reading.SayAllContextList + case "table": + return reading.SayAllContextTable + case "panel": + return reading.SayAllContextPanel + case "landmark": + return reading.SayAllContextLandmark + case "form": + return reading.SayAllContextNonLandmarkForm + } + return false +} + +func ContextLabel(snapshot atspi.Snapshot) string { + role := ContextType(snapshot) + if role == "blockquote" { + role = "block quote" + } + if role == "landmark" && snapshot.XMLRoles != "" { + role = strings.Fields(snapshot.XMLRoles)[0] + " landmark" + } + if snapshot.Name != "" { + return snapshot.Name + " " + role + } + return role +} + +func itemCountText(count int) string { + if count == 1 { + return "1 item" + } + return fmt.Sprintf("%d items", count) +} + +// TableHeaderText preserves the provider's header label and adds the role +// wording used by verbose speech. Brief speech keeps the label alone. +func TableHeaderText(label, role string, verbosity int) string { + if label == "" || verbosity == 0 { + return label + } + return label + " " + role +} + +// ContextCountLabel adds provider-backed list size to a new context entry. +// The ordinary context label remains stable for exits and brief speech. +func ContextCountLabel(snapshot atspi.Snapshot, verbosity int) string { + if verbosity != 0 && snapshot.ChildCountKnown && snapshot.ChildCount > 0 && + (snapshot.Role == "list" || snapshot.Role == "list box") { + if snapshot.Name != "" { + return snapshot.Name + ", " + itemCountText(snapshot.ChildCount) + } + return itemCountText(snapshot.ChildCount) + } + return ContextLabel(snapshot) +} diff --git a/internal/presentation/reading_test.go b/internal/presentation/reading_test.go new file mode 100644 index 0000000..03d7953 --- /dev/null +++ b/internal/presentation/reading_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestReadingPreferencesChangeControlOutput(t *testing.T) { + reading := config.DefaultSettings().Reading + options := DefaultOptions() + options.Reading = &reading + snapshot := atspi.Snapshot{Name: "Save", Role: "button", Description: "Save document", Mnemonic: "s", PositionInSet: 2, SetSize: 3} + reading.EnableMnemonicSpeaking, reading.EnablePositionSpeaking, reading.EnableTutorialMessages = true, true, true + text := FocusTextWithOptions(snapshot, options) + want := "Save, button, Save document, 2 of 3, s, Press Space to activate." + if text != want { + t.Fatalf("enabled extras = %q, want %q", text, want) + } + reading.EnableMnemonicSpeaking, reading.EnablePositionSpeaking, reading.EnableTutorialMessages = false, false, false + if got := FocusTextWithOptions(snapshot, options); got != "Save, button, Save document" { + t.Fatalf("disabled extras: %q", got) + } + reading.EnablePauseBreaks = false + if got := FocusTextWithOptions(snapshot, options); got != "Save button Save document" { + t.Fatalf("pause breaks survived: %q", got) + } + reading.OnlySpeakDisplayedText = true + if got := FocusTextWithOptions(snapshot, options); got != "Save" { + t.Fatalf("displayed text: %q", got) + } + if got := EventTextWithOptions(atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Role: "check box", Checked: true}), options); got != "" { + t.Fatalf("state speech survived displayed-only: %q", got) + } + reading.OnlySpeakDisplayedText = false + line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Text: "word", Snapshot: atspi.Snapshot{Role: "text", Misspelled: true}}}} + if got := DocumentLineTextWithOptions(line, options); got != "word misspelled" { + t.Fatalf("misspelling: %q", got) + } + reading.SpeakMisspelledIndicator = false + if got := DocumentLineTextWithOptions(line, options); got != "word" { + t.Fatalf("disabled misspelling: %q", got) + } +} + +func TestDocumentAndSpreadsheetFullRowsUseIndependentFlags(t *testing.T) { + reading := config.DefaultSettings().Reading + options := DefaultOptions() + options.Reading = &reading + cell := atspi.Snapshot{Role: "table cell", Name: "Cell one", TableRowText: "Cell one, Cell two"} + if got := FocusTextWithOptions(cell, options); got != cell.TableRowText { + t.Fatalf("document row = %q", got) + } + reading.ReadFullRowInDocumentTable = false + if got := FocusTextWithOptions(cell, options); got != cell.Name { + t.Fatalf("document cell = %q", got) + } + cell.Spreadsheet = true + reading.ReadFullRowInDocumentTable = true + if got := FocusTextWithOptions(cell, options); got != cell.Name { + t.Fatalf("document flag affected spreadsheet: %q", got) + } + reading.ReadFullRowInSpreadSheet = true + if got := FocusTextWithOptions(cell, options); got != cell.TableRowText { + t.Fatalf("spreadsheet row = %q", got) + } +} + +func TestDocumentLineReadsEachTableRowOnce(t *testing.T) { + line := atspi.DocumentLine{} + for _, name := range []string{"One", "Two"} { + line.Contents = append(line.Contents, atspi.DocumentContent{Text: name, Snapshot: atspi.Snapshot{ + BusName: ":1.42", Path: "/" + name, Role: "table cell", TablePath: "/table", TableRowText: "One, Two", + }}) + } + if got := DocumentLineText(line); got != "One, Two" { + t.Fatalf("repeated row: %q", got) + } +} + +func TestEachNavigationContextHasItsOwnPreference(t *testing.T) { + for _, tc := range []struct { + snapshot atspi.Snapshot + kind string + disable func(*config.ReadingSettings) + }{ + {atspi.Snapshot{Role: "block quote"}, "blockquote", func(r *config.ReadingSettings) { r.SpeakContextBlockquote = false }}, + {atspi.Snapshot{Role: "list"}, "list", func(r *config.ReadingSettings) { r.SpeakContextList = false }}, + {atspi.Snapshot{Role: "table"}, "table", func(r *config.ReadingSettings) { r.SpeakContextTable = false }}, + {atspi.Snapshot{Role: "panel"}, "panel", func(r *config.ReadingSettings) { r.SpeakContextPanel = false }}, + {atspi.Snapshot{Role: "section", XMLRoles: "navigation"}, "landmark", func(r *config.ReadingSettings) { r.SpeakContextLandmark = false }}, + {atspi.Snapshot{Role: "form"}, "form", func(r *config.ReadingSettings) { r.SpeakContextNonLandmarkForm = false }}, + } { + reading := config.DefaultSettings().Reading + if ContextType(tc.snapshot) != tc.kind || !ContextEnabled(tc.kind, reading) { + t.Fatalf("missing context: %+v", tc) + } + tc.disable(&reading) + if ContextEnabled(tc.kind, reading) { + t.Fatalf("disabled context: %s", tc.kind) + } + } + if ContextType(atspi.Snapshot{Role: "table", LayoutTable: true}) != "" { + t.Fatal("layout table announced") + } +} diff --git a/internal/presentation/sound_cues.go b/internal/presentation/sound_cues.go new file mode 100644 index 0000000..db85fb1 --- /dev/null +++ b/internal/presentation/sound_cues.go @@ -0,0 +1,206 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "math" + "strconv" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +// ProgressPercentage normalizes the native range, retaining fractional values. +// Text percentages are used when a provider has no AT-SPI Value interface. +func ProgressPercentage(snapshot atspi.Snapshot) (float64, bool) { + if snapshot.Role != "progress bar" || snapshot.Defunct || snapshot.Indeterminate { + return 0, false + } + var percentage float64 + if snapshot.ValueKnown { + if math.IsNaN(snapshot.CurrentValue) || math.IsInf(snapshot.CurrentValue, 0) || + math.IsNaN(snapshot.MinimumValue) || math.IsInf(snapshot.MinimumValue, 0) || + math.IsNaN(snapshot.MaximumValue) || math.IsInf(snapshot.MaximumValue, 0) || snapshot.MaximumValue <= snapshot.MinimumValue { + return 0, false + } + percentage = (snapshot.CurrentValue - snapshot.MinimumValue) * 100 / (snapshot.MaximumValue - snapshot.MinimumValue) + } else { + value := strings.Trim(strings.TrimSpace(snapshot.ValueText), "%\x00") + var err error + percentage, err = strconv.ParseFloat(value, 64) + if err != nil { + return 0, false + } + } + if math.IsNaN(percentage) || math.IsInf(percentage, 0) { + return 0, false + } + return min(100, max(0, percentage)), true +} + +func ProgressValueSound(snapshot atspi.Snapshot) string { + percentage, valid := ProgressPercentage(snapshot) + if !valid { + return "" + } + return sound.ProgressCueName(percentage) +} + +// SoundCues uses the same immutable accessible snapshot as speech presentation. +// Role and state cues remain categorized until their independent replacement +// decisions have been made. +func SoundCues(snapshot atspi.Snapshot, options Options) []sound.Cue { + if !options.EnableSound { + return nil + } + if options.RoleSoundPresentation == "speech_only" { + return nil + } + + var cues []sound.Cue + if cue, ok := roleSoundCue(snapshot, options); ok { + cues = append(cues, cue) + } + if cue, ok := stateSoundCue(snapshot, options); ok { + cues = append(cues, cue) + } + if cue, ok := position_sound_cue(snapshot, options); ok { + cues = append(cues, cue) + } + return cues +} + +func position_sound_cue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) { + if !options.EnableSound || !options.PlaySoundForPositionInSet || options.RoleSoundPresentation == "speech_only" || + snapshot.Defunct || snapshot.SetSize <= 1 || snapshot.PositionInSet < 1 || snapshot.PositionInSet > snapshot.SetSize { + return sound.Cue{}, false + } + position := float64(snapshot.PositionInSet-1) / float64(snapshot.SetSize-1) + name := sound.PositionCueName(position) + if !cueAvailable(options, name) { + return sound.Cue{}, false + } + return sound.Cue{Name: name, Pan: objectSoundPan(snapshot, options)}, true +} + +// EventSoundCues avoids replaying a role cue for an event which presents only +// a changed state. Focus and other object presentation still include both. +func EventSoundCues(event atspi.Event, options Options) []sound.Cue { + if event.Type == "object:state-changed:focused" { + return SoundCues(event.Source, options) + } + if strings.HasPrefix(event.Type, "object:state-changed:") { + if !isReplaceableStateEvent(event.Type) { + return nil + } + if cue, ok := stateSoundCue(event.Source, options); ok { + return []sound.Cue{cue} + } + return nil + } + return SoundCues(event.Source, options) +} + +func roleSoundCue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) { + if !options.EnableSound || !options.PlaySoundForRole || options.RoleSoundPresentation == "speech_only" { + return sound.Cue{}, false + } + name := map[string]string{ + "push button": "button", + "button": "button", + "toggle button": "button", + "combo box": "combobox", + }[snapshot.Role] + if snapshot.Role == "link" { + name = "link" + if snapshot.Visited { + name = "link_visited" + } + } + if name == "" || !cueAvailable(options, name) { + return sound.Cue{}, false + } + return sound.Cue{Name: name, Pan: objectSoundPan(snapshot, options)}, true +} + +func stateSoundCue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) { + if !options.EnableSound || !options.PlaySoundForState || options.RoleSoundPresentation == "speech_only" { + return sound.Cue{}, false + } + name := "" + stateBase := map[string]string{ + "check box": "checkbox", + "check menu item": "checkbox", + "radio button": "radiobutton", + "radio menu item": "radiobutton", + "toggle button": "togglebutton", + "switch": "switch", + }[snapshot.Role] + switch { + case stateBase == "": + case snapshot.Indeterminate && stateBase == "checkbox": + name = stateBase + "_mixed" + case stateBase == "switch": + if snapshot.Checked { + name = "switch_on" + } else { + name = "switch_off" + } + case snapshot.Checked: + name = stateBase + "_checked" + default: + name = stateBase + "_unchecked" + } + if name == "" || !cueAvailable(options, name) { + return sound.Cue{}, false + } + return sound.Cue{Name: name, Pan: objectSoundPan(snapshot, options)}, true +} + +func cueAvailable(options Options, name string) bool { + return options.SoundCueAvailable == nil || options.SoundCueAvailable(name) +} + +func objectSoundPan(snapshot atspi.Snapshot, options Options) float64 { + if !options.SpatializeObjectSounds { + return 0 + } + geometry := snapshot.SoundGeometry + object, reference := geometry.Object, geometry.Reference + if !geometry.ObjectKnown || !geometry.ReferenceKnown || !object.Valid() || !reference.Valid() { + return 0 + } + objectLeft := int64(object.X) + objectRight := objectLeft + int64(object.Width) + referenceLeft := int64(reference.X) + referenceRight := referenceLeft + int64(reference.Width) + if objectLeft < referenceLeft || objectRight > referenceRight { + return 0 + } + center := float64(objectLeft) + float64(object.Width)/2 + position := (center - float64(referenceLeft)) / float64(reference.Width) + const edge = 0.05 + if position <= edge { + return -1 + } + if position >= 1-edge { + return 1 + } + return ((position-edge)/(1-2*edge)*2 - 1) +} diff --git a/internal/presentation/sound_cues_test.go b/internal/presentation/sound_cues_test.go new file mode 100644 index 0000000..c54ead9 --- /dev/null +++ b/internal/presentation/sound_cues_test.go @@ -0,0 +1,251 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "math" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func TestRoleAndStateSoundPolicy(t *testing.T) { + options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true} + for _, test := range []struct { + snapshot atspi.Snapshot + want []string + }{ + {atspi.Snapshot{Role: "push button"}, []string{"button"}}, + {atspi.Snapshot{Role: "check box", Checked: true}, []string{"checkbox_checked"}}, + {atspi.Snapshot{Role: "check box", Indeterminate: true}, []string{"checkbox_mixed"}}, + {atspi.Snapshot{Role: "check box"}, []string{"checkbox_unchecked"}}, + {atspi.Snapshot{Role: "toggle button", Checked: true}, []string{"button", "togglebutton_checked"}}, + {atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}}, + {atspi.Snapshot{Role: "entry"}, nil}, + {atspi.Snapshot{Role: "terminal"}, nil}, + } { + if got := cueNames(SoundCues(test.snapshot, options)); !reflect.DeepEqual(got, test.want) { + t.Fatalf("cues = %#v, want %#v", got, test.want) + } + } + options.RoleSoundPresentation = "sound_only" + if got := FocusTextWithOptions(atspi.Snapshot{Name: "Save", Role: "push button"}, options); got != "Save" { + t.Fatal(got) + } + options.EnableSound = false + if got := FocusTextWithOptions(atspi.Snapshot{Name: "Save", Role: "push button"}, options); got != "Save, push button" { + t.Fatal(got) + } + options.EnableSound = true + options.RoleSoundPresentation = "speech_only" + if got := SoundCues(atspi.Snapshot{Role: "push button"}, options); got != nil { + t.Fatalf("speech-only role presentation produced cues %#v", got) + } + options.RoleSoundPresentation = "sound_and_speech" + options.PlaySoundForRole = false + if got := cueNames(SoundCues(atspi.Snapshot{Role: "toggle button", Checked: true}, options)); !reflect.DeepEqual(got, []string{"togglebutton_checked"}) { + t.Fatalf("disabled role sound produced cues %#v", got) + } + options.PlaySoundForRole = true + options.PlaySoundForState = false + if got := cueNames(SoundCues(atspi.Snapshot{Role: "toggle button", Checked: true}, options)); !reflect.DeepEqual(got, []string{"button"}) { + t.Fatalf("disabled state sound produced cues %#v", got) + } + options.PlaySoundForRole = false + if got := SoundCues(atspi.Snapshot{Role: "toggle button", Checked: true}, options); got != nil { + t.Fatalf("disabled role and state sounds produced cues %#v", got) + } +} + +func TestFocusedEventSoundCues(t *testing.T) { + options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: "sound_and_speech"} + for _, test := range []struct { + name string + snapshot atspi.Snapshot + want []string + }{ + {"button", atspi.Snapshot{Role: "button"}, []string{"button"}}, + {"link", atspi.Snapshot{Role: "link"}, []string{"link"}}, + {"visited link", atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}}, + {"checked toggle", atspi.Snapshot{Role: "toggle button", Checked: true}, []string{"button", "togglebutton_checked"}}, + } { + t.Run(test.name, func(t *testing.T) { + for _, eventType := range []string{"focus:", "object:state-changed:focused"} { + event := atspi.NewEvent(eventType, test.snapshot) + if got := cueNames(EventSoundCues(event, options)); !reflect.DeepEqual(got, test.want) { + t.Errorf("%s cues = %v, want %v", eventType, got, test.want) + } + } + }) + } + checked := atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Role: "check box", Checked: true}) + if got := cueNames(EventSoundCues(checked, options)); !reflect.DeepEqual(got, []string{"checkbox_checked"}) { + t.Errorf("checked state cues = %v", got) + } + autocompleteState := atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Role: "entry", Autocomplete: true}) + if got := EventSoundCues(autocompleteState, options); got != nil { + t.Errorf("autocomplete state replayed role cue: %v", got) + } +} + +func cueNames(cues []sound.Cue) []string { + if cues == nil { + return nil + } + names := make([]string, len(cues)) + for index, cue := range cues { + names[index] = cue.Name + } + return names +} + +func TestProgressValueSound(t *testing.T) { + snapshot := atspi.Snapshot{Role: "progress bar", ValueKnown: true, MinimumValue: 0, CurrentValue: 50, MaximumValue: 100} + if got := ProgressValueSound(snapshot); got != "generated-progress-tone:50" { + t.Fatalf("progress sound = %q", got) + } + snapshot.CurrentValue = 200 + if got := ProgressValueSound(snapshot); got != "generated-progress-tone:100" { + t.Fatalf("clamped progress sound = %q", got) + } + snapshot.Role = "slider" + if got := ProgressValueSound(snapshot); got != "" { + t.Fatalf("slider generated progress sound %q", got) + } +} + +func TestProgressPercentagesRetainFractionsAndRejectInvalidValues(t *testing.T) { + for _, step := range []struct { + snapshot atspi.Snapshot + value float64 + valid bool + }{ + {atspi.Snapshot{Role: "progress bar", ValueKnown: true, MinimumValue: 10, CurrentValue: 20, MaximumValue: 30}, 50, true}, + {atspi.Snapshot{Role: "progress bar", ValueText: "12.5%"}, 12.5, true}, + {atspi.Snapshot{Role: "progress bar", ValueText: "101%"}, 100, true}, + {atspi.Snapshot{Role: "progress bar", ValueText: "NaN"}, 0, false}, + {atspi.Snapshot{Role: "progress bar", ValueText: "+Inf"}, 0, false}, + {atspi.Snapshot{Role: "progress bar", ValueText: "working"}, 0, false}, + {atspi.Snapshot{Role: "progress bar", ValueKnown: true, MaximumValue: 0}, 0, false}, + {atspi.Snapshot{Role: "progress bar", ValueText: "50%", Indeterminate: true}, 0, false}, + } { + value, valid := ProgressPercentage(step.snapshot) + if value != step.value || valid != step.valid { + t.Fatalf("progress %+v: %g, %t", step.snapshot, value, valid) + } + } +} + +func TestElementPresentationStrategiesKeepRoleAndStateIndependent(t *testing.T) { + toggle := atspi.Snapshot{Name: "Power", Role: "toggle button", Checked: true} + for _, test := range []struct { + strategy string + role bool + state bool + text string + cues []string + }{ + {"speech_only", true, true, "Power, toggle button, pressed", nil}, + {"sound_and_speech", true, true, "Power, toggle button, pressed", []string{"button", "togglebutton_checked"}}, + {"sound_only", true, true, "Power", []string{"button", "togglebutton_checked"}}, + {"sound_only", false, true, "Power, toggle button", []string{"togglebutton_checked"}}, + {"sound_only", true, false, "Power, pressed", []string{"button"}}, + {"sound_only", false, false, "Power, toggle button, pressed", nil}, + } { + options := Options{EnableSound: true, PlaySoundForRole: test.role, PlaySoundForState: test.state, RoleSoundPresentation: test.strategy} + if got := FocusTextWithOptions(toggle, options); got != test.text { + t.Errorf("%s role=%v state=%v text=%q, want %q", test.strategy, test.role, test.state, got, test.text) + } + if got := cueNames(SoundCues(toggle, options)); !reflect.DeepEqual(got, test.cues) { + t.Errorf("%s role=%v state=%v cues=%v, want %v", test.strategy, test.role, test.state, got, test.cues) + } + } + + checkbox := atspi.Snapshot{Name: "Accept", Role: "check box", Checked: true, Required: true, Invalid: true} + options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: "sound_only"} + if got := FocusTextWithOptions(checkbox, options); got != "Accept, check box, required, invalid entry" { + t.Fatalf("sound-only checkbox text = %q", got) + } + unsupported := atspi.Snapshot{Name: "Document", Role: "text frame", Expandable: true, Expanded: true} + if got := FocusTextWithOptions(unsupported, options); got != "Document, text frame, expanded" { + t.Fatalf("unsupported state text = %q", got) + } + options.SoundCueAvailable = func(string) bool { return false } + if got := FocusTextWithOptions(toggle, options); got != "Power, toggle button, pressed" { + t.Fatalf("unavailable cue fallback text = %q", got) + } +} + +func TestStateChangeSoundOnlySuppressesOnlyReplacedState(t *testing.T) { + options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: "sound_only"} + event := atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Role: "check box", Checked: true}) + if got := EventTextWithOptions(event, options); got != "" { + t.Fatalf("checked state remained spoken: %q", got) + } + unsupported := atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Role: "text frame", Expandable: true, Expanded: true}) + if got := EventTextWithOptions(unsupported, options); got != "expanded" { + t.Fatalf("unsupported expanded state = %q", got) + } + toggleExpanded := atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Role: "toggle button", Checked: true, Expandable: true, Expanded: true}) + if got := EventSoundCues(toggleExpanded, options); got != nil { + t.Fatalf("expanded event replayed unrelated cues %#v", got) + } + options.PlaySoundForState = false + if got := EventTextWithOptions(event, options); got != "checked" { + t.Fatalf("disabled state fallback = %q", got) + } +} + +func TestObjectSoundPanUsesCapturedGeometry(t *testing.T) { + base := atspi.SoundGeometry{ObjectKnown: true, ReferenceKnown: true, Reference: atspi.Rectangle{X: 100, Y: 20, Width: 1000, Height: 600}} + for _, test := range []struct { + name string + object atspi.Rectangle + want float64 + }{ + {"left", atspi.Rectangle{X: 100, Y: 100, Width: 10, Height: 10}, -1}, + {"center", atspi.Rectangle{X: 595, Y: 100, Width: 10, Height: 10}, 0}, + {"right", atspi.Rectangle{X: 1090, Y: 100, Width: 10, Height: 10}, 1}, + {"intermediate", atspi.Rectangle{X: 350, Y: 100, Width: 10, Height: 10}, -0.5444444444444445}, + } { + geometry := base + geometry.Object = test.object + snapshot := atspi.Snapshot{Role: "push button", SoundGeometry: geometry} + options := Options{EnableSound: true, PlaySoundForRole: true, RoleSoundPresentation: "sound_only", SpatializeObjectSounds: true} + cues := SoundCues(snapshot, options) + if len(cues) != 1 || math.Abs(cues[0].Pan-test.want) > 1e-9 { + t.Errorf("%s pan = %#v, want %v", test.name, cues, test.want) + } + } + invalid := base + invalid.Object = atspi.Rectangle{X: 0, Y: 0, Width: 0, Height: 10} + if got := SoundCues(atspi.Snapshot{Role: "push button", SoundGeometry: invalid}, Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true}); got[0].Pan != 0 { + t.Fatalf("invalid geometry pan = %v", got[0].Pan) + } + clipped := base + clipped.Object = atspi.Rectangle{X: 50, Y: 100, Width: 10, Height: 10} + if got := SoundCues(atspi.Snapshot{Role: "push button", SoundGeometry: clipped}, Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true}); got[0].Pan != 0 { + t.Fatalf("clipped geometry pan = %v", got[0].Pan) + } + centered := SoundCues(atspi.Snapshot{Role: "push button", SoundGeometry: base}, Options{EnableSound: true, PlaySoundForRole: true}) + if centered[0].Pan != 0 { + t.Fatalf("disabled spatialization pan = %v", centered[0].Pan) + } +} diff --git a/internal/presentation/speech.go b/internal/presentation/speech.go new file mode 100644 index 0000000..114c1c2 --- /dev/null +++ b/internal/presentation/speech.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +// Speech retains presentation boundaries independently of the display text. +// Source punctuation is always part of its original chunk. +type Speech struct { + Text string + Chunks []string +} + +func SpeechWithOptions(parts []string, options Options) Speech { + var nonempty []string + for _, part := range parts { + if part != "" { + nonempty = append(nonempty, part) + } + } + parts = nonempty + result := Speech{Text: join_speech_parts(parts, options)} + if reading_options(options).EnablePauseBreaks { + result.Chunks = parts + } + return result +} + +// WithText replaces the presentation only when its text has changed. +func (speech Speech) WithText(text string) Speech { + if text == speech.Text { + return speech + } + return Speech{Text: text} +} + +// Prepend and Append carry explicitly added presentation boundaries without +// inspecting source text for punctuation or matching it inside another string. +func (speech Speech) Prepend(text, separator string) Speech { + if text == "" { + return speech + } + if speech.Text == "" { + return Speech{Text: text} + } + result := Speech{Text: text + separator + speech.Text} + if len(speech.Chunks) != 0 { + result.Chunks = append([]string{text}, speech.Chunks...) + } + return result +} + +func (speech Speech) Append(text, separator string) Speech { + if text == "" { + return speech + } + if speech.Text == "" { + return Speech{Text: text} + } + result := Speech{Text: speech.Text + separator + text} + if len(speech.Chunks) != 0 { + result.Chunks = append(append([]string(nil), speech.Chunks...), text) + } + return result +} diff --git a/internal/presentation/typing_echo.go b/internal/presentation/typing_echo.go new file mode 100644 index 0000000..991fd3d --- /dev/null +++ b/internal/presentation/typing_echo.go @@ -0,0 +1,143 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "strings" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TypingEchoText(event atspi.Event, previousText string, options Options) string { + text, _ := TypingEcho(event, previousText, options) + return text +} + +func TypingEcho(event atspi.Event, previousText string, options Options) (string, string) { + kind := "" + if event.Type == atspi.TextInsertedEvent && utf8.RuneCountInString(event.Payload) == 1 { + kind = "key" + if options.EnableEchoByCharacter { + kind = "character" + } + } + characterEcho := EventTextWithOptions(event, options) + if event.Type != atspi.TextInsertedEvent || !options.EnableKeyEcho || utf8.RuneCountInString(event.Payload) != 1 { + return characterEcho, kind + } + context := event.Source.TextBeforeCaret + if context == "" { + context = event.Source.Text + } + if (event.Payload == "\n" || event.Payload == "\t") && strings.TrimSpace(context) == "" { + context = previousText + } + if options.EnableEchoBySentence { + if sentence := completedSentence(context, event.Payload); sentence != "" { + return sentence, "sentence" + } + } + if options.EnableEchoByWord { + if word := completedWord(context, event.Payload); word != "" { + return word, "word" + } + } + return characterEcho, kind +} + +func completedWord(context, inserted string) string { + if !isWordDelimiter(inserted) { + return "" + } + context = strings.TrimSuffix(context, inserted) + characters := []rune(context) + if len(characters) == 0 || isWordDelimiter(string(characters[len(characters)-1])) { + return "" + } + start := len(characters) - 1 + for start >= 0 && !isWordDelimiter(string(characters[start])) { + start-- + } + return string(characters[start+1:]) +} + +func completedSentence(context, inserted string) string { + context = strings.TrimSuffix(context, inserted) + characters := []rune(context) + if len(characters) == 0 { + return "" + } + if inserted != "\n" && inserted != "\r" { + if !unicode.IsSpace(firstRune(inserted)) || !strings.ContainsRune("!.?:;", characters[len(characters)-1]) { + return "" + } + } + start := 0 + for index := 1; index < len(characters); index++ { + if characters[index] == '\n' || characters[index] == '\r' || + unicode.IsSpace(characters[index]) && strings.ContainsRune("!.?:;", characters[index-1]) { + start = index + 1 + } + } + return strings.TrimSpace(string(characters[start:])) +} + +func isWordDelimiter(value string) bool { + if utf8.RuneCountInString(value) != 1 { + return false + } + r := firstRune(value) + return unicode.IsSpace(r) || strings.ContainsRune("!*+,-./:;<=>?@[\\]^_{|}", r) || r == '\u00a0' +} + +func firstRune(value string) rune { + r, _ := utf8.DecodeRuneInString(value) + return r +} + +func shouldEchoInsertion(text string, options Options) bool { + if text == "" { + return false + } + if utf8.RuneCountInString(text) != 1 { + return true + } + if !options.EnableKeyEcho { + return false + } + r, _ := utf8.DecodeRuneInString(text) + if options.EnableEchoByCharacter && unicode.IsPrint(r) { + return true + } + switch { + case unicode.IsLetter(r): + return options.EnableAlphabeticKeys + case unicode.IsNumber(r): + return options.EnableNumericKeys + case unicode.IsPunct(r) || unicode.IsSymbol(r): + return options.EnablePunctuationKeys + case r == ' ': + return options.EnableSpace + case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Mc, r) || unicode.Is(unicode.Me, r): + return options.EnableDiacriticalKeys + default: + return false + } +} diff --git a/internal/presentation/typing_echo_test.go b/internal/presentation/typing_echo_test.go new file mode 100644 index 0000000..9b60cc6 --- /dev/null +++ b/internal/presentation/typing_echo_test.go @@ -0,0 +1,125 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package presentation + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestEventTextAppliesPrintableKeyEchoSettings(t *testing.T) { + tests := []struct { + name string + payload string + update func(*Options) + want string + }{ + {name: "alphabetic enabled", payload: "a", want: "a"}, + {name: "alphabetic disabled", payload: "a", update: func(options *Options) { options.EnableAlphabeticKeys = false }}, + {name: "numeric disabled", payload: "7", update: func(options *Options) { options.EnableNumericKeys = false }}, + {name: "punctuation disabled", payload: "!", update: func(options *Options) { options.EnablePunctuationKeys = false }}, + {name: "symbol uses punctuation setting", payload: "€", update: func(options *Options) { options.EnablePunctuationKeys = false }}, + {name: "space enabled", payload: " ", want: "space"}, + {name: "space disabled", payload: " ", update: func(options *Options) { options.EnableSpace = false }}, + {name: "diacritical enabled", payload: "́", update: func(options *Options) { options.EnableDiacriticalKeys = true }, want: "́"}, + {name: "character echo overrides category", payload: "a", update: func(options *Options) { + options.EnableAlphabeticKeys = false + options.EnableEchoByCharacter = true + }, want: "a"}, + {name: "key echo disables character echo", payload: "a", update: func(options *Options) { + options.EnableKeyEcho = false + options.EnableEchoByCharacter = true + }}, + {name: "multi-character insertion is not a key echo", payload: "pasted text", update: func(options *Options) { + options.EnableKeyEcho = false + }, want: "pasted text"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + options := DefaultOptions() + if test.update != nil { + test.update(&options) + } + event := atspi.NewEventWithPayload( + atspi.TextInsertedEvent, + atspi.NewSnapshot("/accessible/editor", "", "text", "", ""), + test.payload, + ) + if got := EventTextWithOptions(event, options); got != test.want { + t.Fatalf("EventTextWithOptions() = %q, want %q", got, test.want) + } + }) + } +} + +func TestSentenceEchoStopsAtPreviousLine(t *testing.T) { + options := DefaultOptions() + options.EnableEchoBySentence = true + for _, context := range []string{"First line\nSecond line. ", "First line\rSecond line. "} { + event := atspi.NewEvent(atspi.TextInsertedEvent, atspi.Snapshot{TextBeforeCaret: context}) + event.Payload = " " + if got := TypingEchoText(event, "", options); got != "Second line." { + t.Fatalf("sentence echo = %q, want only the current sentence", got) + } + } +} + +func TestTypingEchoTextAppliesWordAndSentenceSettings(t *testing.T) { + options := DefaultOptions() + options.EnableEchoByWord = true + snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "hello ") + snapshot.Editable = true + snapshot.TextBeforeCaret = "hello " + event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, " ") + if got := TypingEchoText(event, "", options); got != "hello" { + t.Fatalf("word echo = %q, want hello", got) + } + + snapshot.TextBeforeCaret = "hello," + event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, ",") + if got := TypingEchoText(event, "", options); got != "hello" { + t.Fatalf("punctuation word echo = %q, want hello", got) + } + + snapshot.TextBeforeCaret = "hello " + event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, " ") + if got := TypingEchoText(event, "", options); got != "space" { + t.Fatalf("repeated delimiter echo = %q, want space", got) + } + + options.EnableEchoBySentence = true + snapshot.TextBeforeCaret = "First. Second. " + event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, " ") + if got := TypingEchoText(event, "", options); got != "Second." { + t.Fatalf("sentence echo = %q, want Second.", got) + } + + snapshot.TextBeforeCaret = "" + snapshot.Text = "" + event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "\n") + if got := TypingEchoText(event, "Line without punctuation", options); got != "Line without punctuation" { + t.Fatalf("newline sentence echo = %q", got) + } + + options.EnableKeyEcho = false + if got := TypingEchoText(event, "Line without punctuation", options); got != "" { + t.Fatalf("disabled key echo sentence = %q", got) + } +} diff --git a/internal/remote/endpoint.go b/internal/remote/endpoint.go new file mode 100644 index 0000000..e537eb3 --- /dev/null +++ b/internal/remote/endpoint.go @@ -0,0 +1,273 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +// Package remote publishes Cthulhu's bounded self-voice D-Bus API. +package remote + +import ( + "encoding/xml" + "log/slog" + "sync" + + "git.stormux.org/storm/cthulhu/internal/version" + "github.com/godbus/dbus/v5" +) + +type endpoint struct { + logger *slog.Logger + handler Handler + authorize func(dbus.Sender) bool + + mutex sync.Mutex + accepting bool + active int + idle chan struct{} +} + +func newEndpoint(logger *slog.Logger, handler Handler, authorize func(dbus.Sender) bool) *endpoint { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &endpoint{logger: logger, handler: handler, authorize: authorize} +} + +func (endpoint *endpoint) startAccepting() { + endpoint.mutex.Lock() + endpoint.accepting = true + endpoint.mutex.Unlock() +} + +func (endpoint *endpoint) begin(sender dbus.Sender, method string) *dbus.Error { + endpoint.mutex.Lock() + if !endpoint.accepting { + endpoint.mutex.Unlock() + endpoint.logger.Debug("self-voice D-Bus call unavailable", "method", method, "sender", sender) + return &dbus.Error{ + Name: "org.stormux.Cthulhu1.Error.Unavailable", + Body: []any{"self-voice service is not accepting requests"}, + } + } + endpoint.active++ + endpoint.mutex.Unlock() + if endpoint.authorize == nil || !endpoint.authorize(sender) { + endpoint.end() + endpoint.logger.Warn("self-voice D-Bus call denied", "method", method, "sender", sender) + return &dbus.Error{ + Name: "org.freedesktop.DBus.Error.AccessDenied", + Body: []any{"self-voice requests are restricted to the runtime user"}, + } + } + endpoint.logger.Debug("self-voice D-Bus call accepted", "method", method, "sender", sender) + return nil +} + +func (endpoint *endpoint) end() { + endpoint.mutex.Lock() + endpoint.active-- + if endpoint.active == 0 && endpoint.idle != nil { + close(endpoint.idle) + endpoint.idle = nil + } + endpoint.mutex.Unlock() +} + +func (endpoint *endpoint) stopAccepting() <-chan struct{} { + endpoint.mutex.Lock() + defer endpoint.mutex.Unlock() + endpoint.accepting = false + if endpoint.active == 0 { + idle := make(chan struct{}) + close(idle) + return idle + } + if endpoint.idle == nil { + endpoint.idle = make(chan struct{}) + } + return endpoint.idle +} + +func (endpoint *endpoint) logRequest(method string) { + endpoint.logger.Debug("self-voice D-Bus request", "method", method) +} + +func (endpoint *endpoint) logResponse(method string, result any) { + endpoint.logger.Debug("self-voice D-Bus response", "method", method, "result", result) +} + +func (endpoint *endpoint) PresentMessage(sender dbus.Sender, message string) (bool, *dbus.Error) { + endpoint.logRequest("PresentMessage") + if err := endpoint.begin(sender, "PresentMessage"); err != nil { + return false, err + } + defer endpoint.end() + if len(message) > maxTextBytes { + endpoint.logResponse("PresentMessage", false) + return false, nil + } + accepted := endpoint.handler.PresentMessage(message) + endpoint.logResponse("PresentMessage", accepted) + return accepted, nil +} + +func (endpoint *endpoint) ShowPreferences(sender dbus.Sender) (bool, *dbus.Error) { + if err := endpoint.begin(sender, "ShowPreferences"); err != nil { + return false, err + } + defer endpoint.end() + if handler, ok := endpoint.handler.(ControlHandler); ok { + return handler.ShowPreferences(), nil + } + return false, nil +} + +func (endpoint *endpoint) GetVersion(sender dbus.Sender) (string, *dbus.Error) { + if err := endpoint.begin(sender, "GetVersion"); err != nil { + return "", err + } + defer endpoint.end() + return version.String(), nil +} + +func (endpoint *endpoint) GetRuntimeStateSnapshot(sender dbus.Sender) (string, *dbus.Error) { + if err := endpoint.begin(sender, "GetRuntimeStateSnapshot"); err != nil { + return "", err + } + defer endpoint.end() + if handler, ok := endpoint.handler.(ControlHandler); ok { + return handler.RuntimeStateSnapshot(), nil + } + return "", nil +} + +func (endpoint *endpoint) Quit(sender dbus.Sender) (bool, *dbus.Error) { + if err := endpoint.begin(sender, "Quit"); err != nil { + return false, err + } + defer endpoint.end() + if handler, ok := endpoint.handler.(ControlHandler); ok { + return handler.Quit(), nil + } + return false, nil +} + +func (endpoint *endpoint) SpeakText(sender dbus.Sender, text string, interrupt bool) (bool, *dbus.Error) { + endpoint.logRequest("SpeakText") + if err := endpoint.begin(sender, "SpeakText"); err != nil { + return false, err + } + defer endpoint.end() + if text == "" || len(text) > maxTextBytes { + endpoint.logResponse("SpeakText", false) + return false, nil + } + accepted := endpoint.handler.SpeakText(text, interrupt) + endpoint.logResponse("SpeakText", accepted) + return accepted, nil +} + +func (endpoint *endpoint) BrailleMessage(sender dbus.Sender, text string) (bool, *dbus.Error) { + endpoint.logRequest("BrailleMessage") + if err := endpoint.begin(sender, "BrailleMessage"); err != nil { + return false, err + } + defer endpoint.end() + if text == "" || len(text) > maxTextBytes { + endpoint.logResponse("BrailleMessage", false) + return false, nil + } + accepted := endpoint.handler.BrailleMessage(text) + endpoint.logResponse("BrailleMessage", accepted) + return accepted, nil +} + +func (endpoint *endpoint) CancelSpeech(sender dbus.Sender) (bool, *dbus.Error) { + endpoint.logRequest("CancelSpeech") + if err := endpoint.begin(sender, "CancelSpeech"); err != nil { + return false, err + } + defer endpoint.end() + accepted := endpoint.handler.CancelSpeech() + endpoint.logResponse("CancelSpeech", accepted) + return accepted, nil +} + +func (endpoint *endpoint) SpeakSsml(sender dbus.Sender, requestID, ssml string) (int32, *dbus.Error) { + endpoint.logRequest("SpeakSsml") + if err := endpoint.begin(sender, "SpeakSsml"); err != nil { + return windowsErrorInvalidParameter, err + } + defer endpoint.end() + if len(requestID) > maxRequestIDBytes || len(ssml) > maxTextBytes || !validSSML(ssml) { + endpoint.logResponse("SpeakSsml", windowsErrorInvalidParameter) + return windowsErrorInvalidParameter, nil + } + endpoint.logResponse("SpeakSsml", windowsErrorNotSupported) + return windowsErrorNotSupported, nil +} + +func (endpoint *endpoint) PresentAccessibleEvent(sender dbus.Sender, sourceID, eventType, name, role, value, states, description string, position, count int32, windowTitle string) (bool, *dbus.Error) { + endpoint.logRequest("PresentAccessibleEvent") + if err := endpoint.begin(sender, "PresentAccessibleEvent"); err != nil { + return false, err + } + defer endpoint.end() + for _, field := range []string{sourceID, eventType, name, role, value, states, description, windowTitle} { + if len(field) > 4096 { + endpoint.logResponse("PresentAccessibleEvent", false) + return false, nil + } + } + if sourceID == "" { + endpoint.logResponse("PresentAccessibleEvent", false) + return false, nil + } + accepted := endpoint.handler.PresentAccessibleEvent(AccessibleEvent{ + SourceID: sourceID, EventType: eventType, Name: name, Role: role, + Value: value, States: states, Description: description, + Position: position, Count: count, WindowTitle: windowTitle, + }) + endpoint.logResponse("PresentAccessibleEvent", accepted) + return accepted, nil +} + +func validSSML(value string) bool { + var root struct { + XMLName xml.Name + } + if err := xml.Unmarshal([]byte(value), &root); err != nil { + return false + } + return root.XMLName.Local == "speak" +} + +// speechManagerEndpoint shares authorization and shutdown accounting with the +// main service. Python's notify_user argument does not affect speech cancellation. +type speechManagerEndpoint struct { + endpoint *endpoint +} + +func (manager *speechManagerEndpoint) InterruptSpeech(sender dbus.Sender, _ bool) (bool, *dbus.Error) { + endpoint := manager.endpoint + endpoint.logRequest("InterruptSpeech") + if err := endpoint.begin(sender, "InterruptSpeech"); err != nil { + return false, err + } + defer endpoint.end() + accepted := endpoint.handler.CancelSpeech() + endpoint.logResponse("InterruptSpeech", accepted) + return accepted, nil +} diff --git a/internal/remote/endpoint_test.go b/internal/remote/endpoint_test.go new file mode 100644 index 0000000..1ea418e --- /dev/null +++ b/internal/remote/endpoint_test.go @@ -0,0 +1,207 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package remote + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestEndpointDiagnosticsDoNotLogRequestContents(t *testing.T) { + const private = "Private game text 7a9e" + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + endpoint := newEndpoint(logger, &recordingHandler{result: true}, func(dbus.Sender) bool { return true }) + endpoint.startAccepting() + sender := dbus.Sender(":1.5") + endpoint.PresentMessage(sender, private) + endpoint.SpeakText(sender, private, true) + endpoint.BrailleMessage(sender, private) + endpoint.SpeakSsml(sender, private, private) + endpoint.PresentAccessibleEvent(sender, private, private, private, private, private, + private, private, 1, 1, private) + if strings.Contains(output.String(), private) { + t.Fatalf("debug diagnostics exposed request text: %q", output.String()) + } + if !strings.Contains(output.String(), `"method":"PresentAccessibleEvent"`) { + t.Fatalf("debug diagnostics lost method outcome: %q", output.String()) + } +} + +func TestEndpointDispatchesSpeechMethods(t *testing.T) { + handler := &recordingHandler{result: true} + endpoint := authorizedEndpoint(handler) + sender := dbus.Sender(":1.5") + + if accepted, err := endpoint.PresentMessage(sender, "Status"); err != nil || !accepted { + t.Fatalf("PresentMessage = (%v, %v), want (true, nil)", accepted, err) + } + if accepted, err := endpoint.SpeakText(sender, "Queued", false); err != nil || !accepted { + t.Fatalf("SpeakText queued = (%v, %v), want (true, nil)", accepted, err) + } + if accepted, err := endpoint.SpeakText(sender, "Now", true); err != nil || !accepted { + t.Fatalf("SpeakText interrupting = (%v, %v), want (true, nil)", accepted, err) + } + if accepted, err := endpoint.CancelSpeech(sender); err != nil || !accepted { + t.Fatalf("CancelSpeech = (%v, %v), want (true, nil)", accepted, err) + } + if accepted, err := endpoint.PresentAccessibleEvent(sender, "42:0:0", "focus", "Start", "button", "", "", "", 1, 3, "Game"); err != nil || !accepted { + t.Fatalf("PresentAccessibleEvent = (%v, %v), want (true, nil)", accepted, err) + } + + handler.mutex.Lock() + defer handler.mutex.Unlock() + if got := strings.Join(handler.presented, ","); got != "Status" { + t.Fatalf("presented = %q, want Status", got) + } + if got := strings.Join(handler.spoken, ","); got != "Queued,Now" { + t.Fatalf("spoken = %q, want Queued,Now", got) + } + if len(handler.interrupts) != 2 || handler.interrupts[0] || !handler.interrupts[1] { + t.Fatalf("interrupts = %v, want [false true]", handler.interrupts) + } + if handler.canceled != 1 { + t.Fatalf("canceled = %d, want 1", handler.canceled) + } + if len(handler.wine) != 1 || handler.wine[0].Name != "Start" || handler.wine[0].Count != 3 { + t.Fatalf("wine event = %+v", handler.wine) + } +} + +func TestEndpointLogsMethodsAndOutcomes(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + endpoint := newEndpoint(logger, &recordingHandler{result: true}, func(dbus.Sender) bool { return true }) + endpoint.startAccepting() + + if accepted, err := endpoint.SpeakText(dbus.Sender(":1.9"), "Prism message", true); err != nil || !accepted { + t.Fatalf("SpeakText = (%v, %v), want (true, nil)", accepted, err) + } + if accepted, err := endpoint.PresentAccessibleEvent( + dbus.Sender(":1.9"), "window:1", "focus", "Start", "button", "Ready", "focused", "Description", 1, 3, "Game", + ); err != nil || !accepted { + t.Fatalf("PresentAccessibleEvent = (%v, %v), want (true, nil)", accepted, err) + } + + logged := output.String() + for _, expected := range []string{ + "method=SpeakText", + "method=PresentAccessibleEvent", + "result=true", + } { + if !strings.Contains(logged, expected) { + t.Fatalf("debug log missing %q:\n%s", expected, logged) + } + } + for _, private := range []string{"Prism message", "Start", "Ready", "Description"} { + if strings.Contains(logged, private) { + t.Fatalf("debug log exposed %q:\n%s", private, logged) + } + } +} + +func TestEndpointRejectsUnavailableInvalidAndOversizedRequests(t *testing.T) { + handler := &recordingHandler{result: false} + endpoint := authorizedEndpoint(handler) + sender := dbus.Sender(":1.6") + + if accepted, _ := endpoint.BrailleMessage(sender, "Braille"); accepted { + t.Fatal("BrailleMessage succeeded without a braille backend") + } + if accepted, _ := endpoint.SpeakText(sender, "", false); accepted { + t.Fatal("SpeakText accepted empty text") + } + if accepted, _ := endpoint.SpeakText(sender, strings.Repeat("x", maxTextBytes+1), false); accepted { + t.Fatal("SpeakText accepted oversized text") + } + + handler.mutex.Lock() + defer handler.mutex.Unlock() + if len(handler.spoken) != 0 { + t.Fatalf("invalid speech reached handler: %v", handler.spoken) + } + if got := strings.Join(handler.braille, ","); got != "Braille" { + t.Fatalf("braille handler calls = %q, want Braille", got) + } +} + +func TestEndpointRejectsUnauthorizedAndStoppedCalls(t *testing.T) { + handler := &recordingHandler{result: true} + endpoint := newEndpoint(slog.New(slog.DiscardHandler), handler, func(dbus.Sender) bool { return false }) + endpoint.startAccepting() + if accepted, err := endpoint.SpeakText(dbus.Sender(":1.7"), "Private", false); accepted || err == nil || err.Name != "org.freedesktop.DBus.Error.AccessDenied" { + t.Fatalf("SpeakText = (%v, %v), want access denied", accepted, err) + } + idle := endpoint.stopAccepting() + select { + case <-idle: + default: + t.Fatal("idle endpoint did not stop immediately") + } + if accepted, err := endpoint.CancelSpeech(dbus.Sender(":1.7")); accepted || err == nil || err.Name != "org.stormux.Cthulhu1.Error.Unavailable" { + t.Fatalf("CancelSpeech = (%v, %v), want unavailable", accepted, err) + } +} + +func TestSpeakSsmlMatchesUnsupportedCompatibilityContract(t *testing.T) { + endpoint := authorizedEndpoint(&recordingHandler{result: true}) + sender := dbus.Sender(":1.8") + tests := []struct { + name string + ssml string + want int32 + }{ + {name: "valid", ssml: `Hello`, want: windowsErrorNotSupported}, + {name: "namespace", ssml: `Hello`, want: windowsErrorNotSupported}, + {name: "malformed", ssml: ``, want: windowsErrorInvalidParameter}, + {name: "wrong root", ssml: `Hello`, want: windowsErrorInvalidParameter}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := endpoint.SpeakSsml(sender, "request", test.ssml) + if err != nil || got != test.want { + t.Fatalf("SpeakSsml = (%d, %v), want (%d, nil)", got, err, test.want) + } + }) + } +} + +func TestSpeechManagerAuthorizationAndShutdown(t *testing.T) { + handler := &recordingHandler{result: true} + endpoint := newEndpoint(nil, handler, func(dbus.Sender) bool { return false }) + endpoint.startAccepting() + manager := &speechManagerEndpoint{endpoint: endpoint} + if accepted, err := manager.InterruptSpeech(":1.5", true); accepted || err == nil || err.Name != "org.freedesktop.DBus.Error.AccessDenied" { + t.Fatalf("unauthorized InterruptSpeech: accepted=%v error=%v", accepted, err) + } + endpoint.authorize = func(dbus.Sender) bool { return true } + if accepted, err := manager.InterruptSpeech(":1.5", false); !accepted || err != nil { + t.Fatalf("authorized call: %v, %v", accepted, err) + } + <-endpoint.stopAccepting() + if accepted, err := manager.InterruptSpeech(":1.5", true); accepted || err == nil { + t.Fatal("call accepted after shutdown") + } + if handler.canceled != 1 { + t.Fatalf("cancellations = %d", handler.canceled) + } +} diff --git a/internal/remote/introspection.go b/internal/remote/introspection.go new file mode 100644 index 0000000..0da3c96 --- /dev/null +++ b/internal/remote/introspection.go @@ -0,0 +1,59 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +// Package remote publishes Cthulhu's bounded self-voice D-Bus API. +package remote + +import ( + "github.com/godbus/dbus/v5/introspect" +) + +func introspectionMethods() []introspect.Method { + return serviceMethods(false) +} + +func serviceMethods(orca bool) []introspect.Method { + methods := []introspect.Method{ + {Name: "ShowPreferences", Args: []introspect.Arg{{Name: "accepted", Type: "b", Direction: "out"}}}, + {Name: "GetVersion", Args: []introspect.Arg{{Name: "version", Type: "s", Direction: "out"}}}, + {Name: "Quit", Args: []introspect.Arg{{Name: "accepted", Type: "b", Direction: "out"}}}, + {Name: "PresentMessage", Args: []introspect.Arg{{Name: "message", Type: "s", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}}}, + } + if orca { + return append(methods, introspect.Method{Name: "ListModules", Args: []introspect.Arg{{Name: "modules", Type: "as", Direction: "out"}}}, introspect.Method{Name: "ListCommands", Args: []introspect.Arg{{Name: "commands", Type: "a(ss)", Direction: "out"}}}) + } + return append(methods, + introspect.Method{Name: "RegisterWineReviewProvider", Args: []introspect.Arg{{Name: "path", Type: "o", Direction: "in"}, {Name: "version", Type: "u", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}}}, + introspect.Method{Name: "PresentWineReviewFocus", Args: []introspect.Arg{ + {Name: "instance", Type: "s", Direction: "in"}, {Name: "window", Type: "s", Direction: "in"}, {Name: "focus", Type: "s", Direction: "in"}, {Name: "revision", Type: "t", Direction: "in"}, + {Name: "title", Type: "s", Direction: "in"}, {Name: "role", Type: "s", Direction: "in"}, {Name: "protected", Type: "b", Direction: "in"}, {Name: "bounds", Type: "(iiii)", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}, + }}, + introspect.Method{Name: "GetRuntimeStateSnapshot", Args: []introspect.Arg{{Name: "snapshot", Type: "s", Direction: "out"}}}, + introspect.Method{Name: "SpeakText", Args: []introspect.Arg{{Name: "text", Type: "s", Direction: "in"}, {Name: "interrupt", Type: "b", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}}}, + introspect.Method{Name: "BrailleMessage", Args: []introspect.Arg{{Name: "text", Type: "s", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}}}, + introspect.Method{Name: "CancelSpeech", Args: []introspect.Arg{{Name: "accepted", Type: "b", Direction: "out"}}}, + introspect.Method{Name: "SpeakSsml", Args: []introspect.Arg{{Name: "requestId", Type: "s", Direction: "in"}, {Name: "ssml", Type: "s", Direction: "in"}, {Name: "result", Type: "i", Direction: "out"}}}, + introspect.Method{Name: "PresentAccessibleEvent", Args: []introspect.Arg{ + {Name: "sourceId", Type: "s", Direction: "in"}, {Name: "eventType", Type: "s", Direction: "in"}, + {Name: "name", Type: "s", Direction: "in"}, {Name: "role", Type: "s", Direction: "in"}, + {Name: "value", Type: "s", Direction: "in"}, {Name: "states", Type: "s", Direction: "in"}, + {Name: "description", Type: "s", Direction: "in"}, {Name: "position", Type: "i", Direction: "in"}, + {Name: "count", Type: "i", Direction: "in"}, {Name: "windowTitle", Type: "s", Direction: "in"}, + {Name: "accepted", Type: "b", Direction: "out"}, + }}, + ) +} diff --git a/internal/remote/modules.go b/internal/remote/modules.go new file mode 100644 index 0000000..d4d5d27 --- /dev/null +++ b/internal/remote/modules.go @@ -0,0 +1,386 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package remote + +import ( + "fmt" + "sort" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +const ( + typingEchoInterface = "org.stormux.Cthulhu1.TypingEchoManager" + orcaModuleInterface = "org.gnome.Orca.Module" +) + +// ModuleHandler is implemented by the runtime settings and command owners. +// The remote layer never changes a settings snapshot directly. +type ModuleHandler interface { + RemoteGet(module, property string) (dbus.Variant, bool) + RemoteSet(module, property string, value dbus.Variant) bool + RemoteCommand(module, command string, notify bool) bool +} + +// ModuleAvailability lets a handler suppress modules until its settings owner +// is wired. Handlers without this method are expected to provide their own +// complete module implementation. +type ModuleAvailability interface{ RemoteModulesAvailable() bool } + +type propertySpec struct { + name, signature string + writable bool +} +type commandSpec struct{ name, description string } +type moduleSpec struct { + name string + properties []propertySpec + commands []commandSpec +} + +var modules = []moduleSpec{ + {name: "SpeechManager", properties: []propertySpec{ + {"CurrentServer", "s", false}, {"AvailableServers", "as", false}, + {"CurrentSynthesizer", "s", true}, {"CurrentVoice", "s", true}, + {"Rate", "i", true}, {"Pitch", "d", true}, {"Volume", "d", true}, + {"SpeechIsEnabled", "b", false}, {"PunctuationLevel", "s", true}, + {"CapitalizationStyle", "s", true}, {"SpeakDescription", "b", true}, + {"SpeakRowInGuiTable", "b", true}, + }, commands: []commandSpec{ + {"SelectPreviousSpeechSetting", "Selects the previous speech setting."}, + {"SelectNextSpeechSetting", "Selects the next speech setting."}, + {"DecreaseCurrentSpeechSetting", "Decreases the selected speech setting."}, + {"IncreaseCurrentSpeechSetting", "Increases the selected speech setting."}, + {"SaveSpeechSettings", "Saves the current speech settings."}, + {"InterruptSpeech", "Stops current speech."}, + {"DecreaseRate", "Decreases speech rate."}, {"IncreaseRate", "Increases speech rate."}, + {"DecreasePitch", "Decreases speech pitch."}, {"IncreasePitch", "Increases speech pitch."}, + {"DecreaseVolume", "Decreases speech volume."}, {"IncreaseVolume", "Increases speech volume."}, + }}, + {name: "TypingEchoManager", properties: []propertySpec{ + {"KeyEchoEnabled", "b", true}, {"CharacterEchoEnabled", "b", true}, + {"WordEchoEnabled", "b", true}, {"SentenceEchoEnabled", "b", true}, + {"AlphabeticKeysEnabled", "b", true}, {"NumericKeysEnabled", "b", true}, + {"PunctuationKeysEnabled", "b", true}, {"SpaceEnabled", "b", true}, + {"ModifierKeysEnabled", "b", true}, {"FunctionKeysEnabled", "b", true}, + {"ActionKeysEnabled", "b", true}, {"NavigationKeysEnabled", "b", true}, + {"DiacriticalKeysEnabled", "b", true}, + }, commands: []commandSpec{{"CycleKeyEcho", "Cycles typing echo mode."}}}, +} + +func modulePath(base dbus.ObjectPath, name string) dbus.ObjectPath { + return dbus.ObjectPath(string(base) + "/" + name) +} + +func moduleFor(name string) *moduleSpec { + for i := range modules { + if modules[i].name == name { + return &modules[i] + } + } + return nil +} + +func (spec moduleSpec) property(name string) (propertySpec, bool) { + for _, item := range spec.properties { + if item.name == name { + return item, true + } + } + return propertySpec{}, false +} + +func (spec moduleSpec) command(name string) bool { + for _, item := range spec.commands { + if item.name == name { + return true + } + } + return false +} + +func (spec moduleSpec) introspection(orca bool) introspect.Interface { + iface := "org.stormux.Cthulhu1." + spec.name + if orca { + iface = orcaModuleInterface + } + result := introspect.Interface{Name: iface} + if orca { + result.Methods = orcaModuleMethods() + } else { + for _, command := range spec.commands { + result.Methods = append(result.Methods, introspect.Method{Name: command.name, Args: []introspect.Arg{ + {Name: "notify_user", Type: "b", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}, + }}) + } + for _, property := range spec.properties { + access := "read" + if property.writable { + access = "readwrite" + } + result.Properties = append(result.Properties, introspect.Property{Name: property.name, Type: property.signature, Access: access}) + } + } + return result +} + +func orcaModuleMethods() []introspect.Method { + return []introspect.Method{ + {Name: "ExecuteRuntimeGetter", Args: []introspect.Arg{{Name: "getter_name", Type: "s", Direction: "in"}, {Name: "value", Type: "v", Direction: "out"}}}, + {Name: "ExecuteRuntimeSetter", Args: []introspect.Arg{{Name: "setter_name", Type: "s", Direction: "in"}, {Name: "value", Type: "v", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}}}, + {Name: "ExecuteCommand", Args: []introspect.Arg{{Name: "command_name", Type: "s", Direction: "in"}, {Name: "notify_user", Type: "b", Direction: "in"}, {Name: "accepted", Type: "b", Direction: "out"}}}, + {Name: "ListCommands", Args: []introspect.Arg{{Name: "commands", Type: "a(ss)", Direction: "out"}}}, + {Name: "ListRuntimeGetters", Args: []introspect.Arg{{Name: "getters", Type: "a(ss)", Direction: "out"}}}, + {Name: "ListRuntimeSetters", Args: []introspect.Arg{{Name: "setters", Type: "a(ss)", Direction: "out"}}}, + {Name: "ListParameterizedCommands", Args: []introspect.Arg{{Name: "commands", Type: "a(ssa(ss))", Direction: "out"}}}, + {Name: "ExecuteParameterizedCommand", Args: []introspect.Arg{{Name: "command_name", Type: "s", Direction: "in"}, {Name: "parameters", Type: "a{sv}", Direction: "in"}, {Name: "notify_user", Type: "b", Direction: "in"}, {Name: "result", Type: "v", Direction: "out"}}}, + } +} + +type namedDescription struct{ Name, Description string } +type parameterizedDescription struct { + Name, Description string + Parameters []namedDescription +} + +type moduleEndpoint struct { + endpoint *endpoint + spec moduleSpec +} + +func (module *moduleEndpoint) control() ModuleHandler { + handler, _ := module.endpoint.handler.(ModuleHandler) + return handler +} + +func unsupported(message string) *dbus.Error { + return &dbus.Error{Name: "org.freedesktop.DBus.Error.NotSupported", Body: []any{message}} +} + +func (module *moduleEndpoint) ExecuteRuntimeGetter(sender dbus.Sender, name string) (dbus.Variant, *dbus.Error) { + if err := module.endpoint.begin(sender, "ExecuteRuntimeGetter"); err != nil { + return dbus.Variant{}, err + } + defer module.endpoint.end() + if len(name) > 256 { + return dbus.Variant{}, unsupported("getter name is too long") + } + if _, ok := module.spec.property(name); !ok || module.control() == nil { + return dbus.Variant{}, unsupported("getter is unavailable") + } + if value, ok := module.control().RemoteGet(module.spec.name, name); ok { + return value, nil + } + return dbus.Variant{}, unsupported("getter is unavailable") +} + +func (module *moduleEndpoint) ExecuteRuntimeSetter(sender dbus.Sender, name string, value dbus.Variant) (bool, *dbus.Error) { + if err := module.endpoint.begin(sender, "ExecuteRuntimeSetter"); err != nil { + return false, err + } + defer module.endpoint.end() + property, ok := module.spec.property(name) + if !ok || !property.writable || module.control() == nil { + return false, unsupported("setter is unavailable") + } + if value.Signature().String() != property.signature { + return false, &dbus.Error{Name: "org.freedesktop.DBus.Error.InvalidArgs", Body: []any{"property type mismatch"}} + } + return module.control().RemoteSet(module.spec.name, name, value), nil +} + +func (module *moduleEndpoint) ExecuteCommand(sender dbus.Sender, name string, notify bool) (bool, *dbus.Error) { + if err := module.endpoint.begin(sender, "ExecuteCommand"); err != nil { + return false, err + } + defer module.endpoint.end() + if !module.spec.command(name) || module.control() == nil { + return false, unsupported("command is unavailable") + } + return module.control().RemoteCommand(module.spec.name, name, notify), nil +} + +func (module *moduleEndpoint) ListCommands(sender dbus.Sender) ([]namedDescription, *dbus.Error) { + if err := module.endpoint.begin(sender, "ListCommands"); err != nil { + return nil, err + } + defer module.endpoint.end() + list := make([]namedDescription, 0, len(module.spec.commands)) + for _, item := range module.spec.commands { + list = append(list, namedDescription{item.name, item.description}) + } + return list, nil +} +func (module *moduleEndpoint) ListRuntimeGetters(sender dbus.Sender) ([]namedDescription, *dbus.Error) { + if err := module.endpoint.begin(sender, "ListRuntimeGetters"); err != nil { + return nil, err + } + defer module.endpoint.end() + list := make([]namedDescription, 0, len(module.spec.properties)) + for _, item := range module.spec.properties { + list = append(list, namedDescription{item.name, "Returns " + item.name + "."}) + } + return list, nil +} +func (module *moduleEndpoint) ListRuntimeSetters(sender dbus.Sender) ([]namedDescription, *dbus.Error) { + if err := module.endpoint.begin(sender, "ListRuntimeSetters"); err != nil { + return nil, err + } + defer module.endpoint.end() + list := []namedDescription{} + for _, item := range module.spec.properties { + if item.writable { + list = append(list, namedDescription{item.name, "Sets " + item.name + " for this session."}) + } + } + return list, nil +} +func (module *moduleEndpoint) ListParameterizedCommands(sender dbus.Sender) ([]parameterizedDescription, *dbus.Error) { + if err := module.endpoint.begin(sender, "ListParameterizedCommands"); err != nil { + return nil, err + } + defer module.endpoint.end() + return []parameterizedDescription{}, nil +} +func (module *moduleEndpoint) ExecuteParameterizedCommand(sender dbus.Sender, _ string, _ map[string]dbus.Variant, _ bool) (dbus.Variant, *dbus.Error) { + if err := module.endpoint.begin(sender, "ExecuteParameterizedCommand"); err != nil { + return dbus.Variant{}, err + } + defer module.endpoint.end() + return dbus.Variant{}, unsupported("parameterized command is unavailable") +} + +type modulePropertiesEndpoint struct { + module *moduleEndpoint + iface string +} + +func (properties *modulePropertiesEndpoint) Get(sender dbus.Sender, iface, name string) (dbus.Variant, *dbus.Error) { + module := properties.module + if err := module.endpoint.begin(sender, "Properties.Get"); err != nil { + return dbus.Variant{}, err + } + defer module.endpoint.end() + if iface != properties.iface { + return dbus.Variant{}, &dbus.Error{Name: "org.freedesktop.DBus.Error.UnknownInterface", Body: []any{iface}} + } + if _, ok := module.spec.property(name); !ok { + return dbus.Variant{}, &dbus.Error{Name: "org.freedesktop.DBus.Error.UnknownProperty", Body: []any{name}} + } + if module.control() == nil { + return dbus.Variant{}, unsupported("property is unavailable") + } + value, ok := module.control().RemoteGet(module.spec.name, name) + if !ok { + return dbus.Variant{}, unsupported("property is unavailable") + } + return value, nil +} +func (properties *modulePropertiesEndpoint) Set(sender dbus.Sender, iface, name string, value dbus.Variant) *dbus.Error { + module := properties.module + if err := module.endpoint.begin(sender, "Properties.Set"); err != nil { + return err + } + defer module.endpoint.end() + if iface != properties.iface { + return &dbus.Error{Name: "org.freedesktop.DBus.Error.UnknownInterface", Body: []any{iface}} + } + property, ok := module.spec.property(name) + if !ok { + return &dbus.Error{Name: "org.freedesktop.DBus.Error.UnknownProperty", Body: []any{name}} + } + if !property.writable { + return &dbus.Error{Name: "org.freedesktop.DBus.Error.PropertyReadOnly", Body: []any{name}} + } + if value.Signature().String() != property.signature { + return &dbus.Error{Name: "org.freedesktop.DBus.Error.InvalidArgs", Body: []any{fmt.Sprintf("%s requires %s", name, property.signature)}} + } + if module.control() == nil || !module.control().RemoteSet(module.spec.name, name, value) { + return unsupported("setting was not applied") + } + return nil +} +func (properties *modulePropertiesEndpoint) GetAll(sender dbus.Sender, iface string) (map[string]dbus.Variant, *dbus.Error) { + module := properties.module + if err := module.endpoint.begin(sender, "Properties.GetAll"); err != nil { + return nil, err + } + defer module.endpoint.end() + if iface != properties.iface { + return nil, &dbus.Error{Name: "org.freedesktop.DBus.Error.UnknownInterface", Body: []any{iface}} + } + values := make(map[string]dbus.Variant) + if module.control() == nil { + return nil, unsupported("properties are unavailable") + } + for _, property := range module.spec.properties { + if value, ok := module.control().RemoteGet(module.spec.name, property.name); ok { + values[property.name] = value + } + } + return values, nil +} + +func sortedModuleNames() []string { + names := make([]string, 0, len(modules)) + for _, module := range modules { + names = append(names, module.name) + } + sort.Strings(names) + return names +} + +func nativeCommand(endpoint *endpoint, module, name string, sender dbus.Sender, notify bool) (bool, *dbus.Error) { + if err := endpoint.begin(sender, name); err != nil { + return false, err + } + defer endpoint.end() + handler, ok := endpoint.handler.(ModuleHandler) + if !ok { + return false, unsupported("command is unavailable") + } + return handler.RemoteCommand(module, name, notify), nil +} + +func (manager *speechManagerEndpoint) SelectPreviousSpeechSetting(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "SelectPreviousSpeechSetting", sender, notify) +} +func (manager *speechManagerEndpoint) SelectNextSpeechSetting(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "SelectNextSpeechSetting", sender, notify) +} +func (manager *speechManagerEndpoint) DecreaseCurrentSpeechSetting(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "DecreaseCurrentSpeechSetting", sender, notify) +} +func (manager *speechManagerEndpoint) IncreaseCurrentSpeechSetting(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "IncreaseCurrentSpeechSetting", sender, notify) +} +func (manager *speechManagerEndpoint) SaveSpeechSettings(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "SaveSpeechSettings", sender, notify) +} +func (manager *speechManagerEndpoint) DecreaseRate(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "DecreaseRate", sender, notify) +} +func (manager *speechManagerEndpoint) IncreaseRate(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "IncreaseRate", sender, notify) +} +func (manager *speechManagerEndpoint) DecreasePitch(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "DecreasePitch", sender, notify) +} +func (manager *speechManagerEndpoint) IncreasePitch(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "IncreasePitch", sender, notify) +} +func (manager *speechManagerEndpoint) DecreaseVolume(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "DecreaseVolume", sender, notify) +} +func (manager *speechManagerEndpoint) IncreaseVolume(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "SpeechManager", "IncreaseVolume", sender, notify) +} + +type typingEchoEndpoint struct{ endpoint *endpoint } + +func (manager *typingEchoEndpoint) CycleKeyEcho(sender dbus.Sender, notify bool) (bool, *dbus.Error) { + return nativeCommand(manager.endpoint, "TypingEchoManager", "CycleKeyEcho", sender, notify) +} diff --git a/internal/remote/modules_test.go b/internal/remote/modules_test.go new file mode 100644 index 0000000..4a88812 --- /dev/null +++ b/internal/remote/modules_test.go @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package remote + +import ( + "context" + "log/slog" + "os" + "strings" + "testing" + + "github.com/godbus/dbus/v5" +) + +type moduleRecordingHandler struct { + recordingHandler + value bool + command string + preferencesShown bool + quitRequested bool +} + +func (handler *moduleRecordingHandler) ShowPreferences() bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.preferencesShown = true + return true +} +func (handler *moduleRecordingHandler) Quit() bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.quitRequested = true + return true +} +func (handler *moduleRecordingHandler) RuntimeStateSnapshot() string { + return "CTHULHU RUNTIME STATE SNAPSHOT\n[event_manager]\noverflow: 0" +} + +func (handler *moduleRecordingHandler) RemoteGet(module, property string) (dbus.Variant, bool) { + if module == "TypingEchoManager" && property == "KeyEchoEnabled" { + handler.mutex.Lock() + defer handler.mutex.Unlock() + return dbus.MakeVariant(handler.value), true + } + return dbus.Variant{}, false +} +func (handler *moduleRecordingHandler) RemoteSet(module, property string, value dbus.Variant) bool { + if module != "TypingEchoManager" || property != "KeyEchoEnabled" { + return false + } + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.value = value.Value().(bool) + return true +} +func (handler *moduleRecordingHandler) RemoteCommand(module, command string, _ bool) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.command = module + "." + command + return true +} + +func TestNativeAndOrcaModuleBus(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus") + } + handler := &moduleRecordingHandler{recordingHandler: recordingHandler{result: true}} + logger := slog.New(slog.DiscardHandler) + native := New(logger, handler) + orca := NewOrca(logger, handler) + if err := native.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { native.Stop(context.Background()) }) + if err := orca.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { orca.Stop(context.Background()) }) + client, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer client.Close() + for _, test := range []struct { + name string + path dbus.ObjectPath + iface string + }{ + {BusName, ObjectPath, InterfaceName}, {OrcaBusName, OrcaObjectPath, OrcaInterfaceName}, + } { + object := client.Object(test.name, test.path) + var version string + if err := object.Call(test.iface+".GetVersion", 0).Store(&version); err != nil || !strings.Contains(version, "Cthulhu") { + t.Fatalf("GetVersion: %q, %v", version, err) + } + var accepted bool + if err := object.Call(test.iface+".ShowPreferences", 0).Store(&accepted); err != nil || !accepted { + t.Fatalf("ShowPreferences: %v, %v", accepted, err) + } + if err := object.Call(test.iface+".Quit", 0).Store(&accepted); err != nil || !accepted { + t.Fatalf("Quit: %v, %v", accepted, err) + } + } + var speechXML string + if err := client.Object(BusName, SpeechManagerPath).Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&speechXML); err != nil { + t.Fatal(err) + } + for _, signature := range []string{`property name="Rate" type="i" access="readwrite"`, `property name="Pitch" type="d" access="readwrite"`, `method name="IncreaseRate"`} { + if !strings.Contains(speechXML, signature) { + t.Fatalf("speech introspection missing %s: %s", signature, speechXML) + } + } + handler.mutex.Lock() + shown, quit := handler.preferencesShown, handler.quitRequested + handler.mutex.Unlock() + if !shown || !quit { + t.Fatal("control calls did not reach runtime owner") + } + var snapshot string + if err := client.Object(BusName, ObjectPath).Call(InterfaceName+".GetRuntimeStateSnapshot", 0).Store(&snapshot); err != nil || !strings.Contains(snapshot, "RUNTIME STATE SNAPSHOT") { + t.Fatalf("snapshot = %q, %v", snapshot, err) + } + + for _, test := range []struct { + name string + path dbus.ObjectPath + iface string + }{ + {BusName, modulePath(ObjectPath, "TypingEchoManager"), typingEchoInterface}, + {OrcaBusName, modulePath(OrcaObjectPath, "TypingEchoManager"), orcaModuleInterface}, + } { + object := client.Object(test.name, test.path) + var xml string + if err := object.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil { + t.Fatal(err) + } + if !strings.Contains(xml, `name="`+test.iface+`"`) { + t.Fatalf("missing interface %s in %s", test.iface, xml) + } + if test.name == BusName { + if !strings.Contains(xml, `property name="KeyEchoEnabled" type="b" access="readwrite"`) { + t.Fatalf("missing property: %s", xml) + } + if !strings.Contains(xml, `method name="CycleKeyEcho"`) { + t.Fatalf("missing command: %s", xml) + } + } else if !strings.Contains(xml, `method name="ExecuteRuntimeSetter"`) { + t.Fatalf("missing generic protocol: %s", xml) + } + } + + nativeObject := client.Object(BusName, modulePath(ObjectPath, "TypingEchoManager")) + var value dbus.Variant + if err := nativeObject.Call("org.freedesktop.DBus.Properties.Get", 0, typingEchoInterface, "KeyEchoEnabled").Store(&value); err != nil { + t.Fatal(err) + } + if value.Value() != false { + t.Fatalf("initial value = %v", value) + } + if err := nativeObject.Call("org.freedesktop.DBus.Properties.Set", 0, typingEchoInterface, "KeyEchoEnabled", dbus.MakeVariant(true)).Err; err != nil { + t.Fatal(err) + } + handler.mutex.Lock() + gotValue := handler.value + handler.mutex.Unlock() + if !gotValue { + t.Fatal("native setter did not reach runtime owner") + } + if err := nativeObject.Call("org.freedesktop.DBus.Properties.Set", 0, typingEchoInterface, "KeyEchoEnabled", dbus.MakeVariant("wrong type")).Err; err == nil { + t.Fatal("wrong type accepted") + } + + orcaObject := client.Object(OrcaBusName, modulePath(OrcaObjectPath, "TypingEchoManager")) + if err := orcaObject.Call(orcaModuleInterface+".ExecuteRuntimeGetter", 0, "KeyEchoEnabled").Store(&value); err != nil { + t.Fatal(err) + } + if value.Value() != true { + t.Fatalf("Orca getter = %v", value) + } + var accepted bool + if err := orcaObject.Call(orcaModuleInterface+".ExecuteRuntimeSetter", 0, "KeyEchoEnabled", dbus.MakeVariant(false)).Store(&accepted); err != nil || !accepted { + t.Fatalf("Orca setter = %v, %v", accepted, err) + } + if err := orcaObject.Call(orcaModuleInterface+".ExecuteCommand", 0, "CycleKeyEcho", true).Store(&accepted); err != nil || !accepted { + t.Fatalf("Orca command = %v, %v", accepted, err) + } + handler.mutex.Lock() + gotCommand := handler.command + handler.mutex.Unlock() + if gotCommand != "TypingEchoManager.CycleKeyEcho" { + t.Fatalf("command owner got %q", gotCommand) + } + if err := orcaObject.Call(orcaModuleInterface+".ExecuteCommand", 0, "Unsupported", true).Err; err == nil { + t.Fatal("unsupported command falsely succeeded") + } + + var names []string + if err := client.Object(OrcaBusName, OrcaObjectPath).Call(OrcaInterfaceName+".ListModules", 0).Store(&names); err != nil { + t.Fatal(err) + } + if strings.Join(names, ",") != "SpeechManager,TypingEchoManager" { + t.Fatalf("modules = %v", names) + } + var commands []namedDescription + if err := client.Object(OrcaBusName, OrcaObjectPath).Call(OrcaInterfaceName+".ListCommands", 0).Store(&commands); err != nil || len(commands) != 6 { + t.Fatalf("service commands = %v, %v", commands, err) + } + if err := orcaObject.Call(orcaModuleInterface+".ListRuntimeGetters", 0).Store(&commands); err != nil || len(commands) != 13 { + t.Fatalf("module getters = %v, %v", commands, err) + } + var parameterized []parameterizedDescription + if err := orcaObject.Call(orcaModuleInterface+".ListParameterizedCommands", 0).Store(¶meterized); err != nil || len(parameterized) != 0 { + t.Fatalf("parameterized commands = %v, %v", parameterized, err) + } + var result dbus.Variant + if err := orcaObject.Call(orcaModuleInterface+".ExecuteParameterizedCommand", 0, "Unsupported", map[string]dbus.Variant{}, false).Store(&result); err == nil { + t.Fatal("unsupported parameterized command falsely succeeded") + } + conflict := NewOrca(logger, handler) + if err := conflict.Start(context.Background()); err == nil { + conflict.Stop(context.Background()) + t.Fatal("second Orca name acquired") + } + if err := orca.Stop(context.Background()); err != nil { + t.Fatal(err) + } + if err := nativeObject.Call("org.freedesktop.DBus.Properties.Get", 0, typingEchoInterface, "KeyEchoEnabled").Store(&value); err != nil { + t.Fatalf("native service lost when Orca stopped: %v", err) + } + replacement := NewOrca(logger, handler) + if err := replacement.Start(context.Background()); err != nil { + t.Fatalf("Orca name not reacquired: %v", err) + } + if err := replacement.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/remote/registrations.go b/internal/remote/registrations.go new file mode 100644 index 0000000..d0af0ed --- /dev/null +++ b/internal/remote/registrations.go @@ -0,0 +1,85 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package remote + +import "github.com/godbus/dbus/v5/introspect" + +// service_bindings builds exports in their existing registration order. Start +// performs exports and rollback; Stop reverses this list before releasing names. +func service_bindings(endpoint *endpoint, orca bool, handler Handler) []serviceBinding { + path, iface := ObjectPath, InterfaceName + if orca { + path, iface = OrcaObjectPath, OrcaInterfaceName + } + var mainEndpoint any = endpoint + if orca { + mainEndpoint = &orcaServiceEndpoint{endpoint: endpoint} + } + registrations := []serviceBinding{ + {path, iface, mainEndpoint}, + {path, "org.freedesktop.DBus.Introspectable", introspect.NewIntrospectable(&introspect.Node{ + Name: string(path), + Interfaces: []introspect.Interface{{Name: iface, Methods: serviceMethods(orca)}}, + })}, + } + if !orca { + registrations[1].value = introspect.NewIntrospectable(&introspect.Node{ + Name: string(path), Interfaces: []introspect.Interface{{Name: iface, Methods: serviceMethods(false)}}, + Children: []introspect.Node{{Name: "SpeechManager"}}, + }) + registrations = append(registrations, serviceBinding{SpeechManagerPath, SpeechManagerInterface, &speechManagerEndpoint{endpoint: endpoint}}) + registrations = append(registrations, serviceBinding{SpeechManagerPath, "org.freedesktop.DBus.Introspectable", introspect.NewIntrospectable(&introspect.Node{ + Name: string(SpeechManagerPath), + Interfaces: []introspect.Interface{{Name: SpeechManagerInterface, Methods: []introspect.Method{ + {Name: "InterruptSpeech", Args: []introspect.Arg{ + {Name: "notify_user", Type: "b", Direction: "in"}, + {Name: "accepted", Type: "b", Direction: "out"}, + }}, + }}}, + })}) + } + _, hasModules := handler.(ModuleHandler) + if available, ok := handler.(ModuleAvailability); ok && !available.RemoteModulesAvailable() { + hasModules = false + } + if hasModules { + children := make([]introspect.Node, 0, len(modules)) + for _, spec := range modules { + module := &moduleEndpoint{endpoint: endpoint, spec: spec} + moduleObjectPath := modulePath(path, spec.name) + moduleIface := "org.stormux.Cthulhu1." + spec.name + var object any + if orca { + moduleIface = orcaModuleInterface + object = module + } else if spec.name == "SpeechManager" { + // SpeechManager was registered above for existing self-voice clients. + object = nil + } else { + object = &typingEchoEndpoint{endpoint: endpoint} + } + if object != nil { + registrations = append(registrations, serviceBinding{moduleObjectPath, moduleIface, object}) + } + registrations = append(registrations, serviceBinding{moduleObjectPath, "org.freedesktop.DBus.Properties", &modulePropertiesEndpoint{module: module, iface: moduleIface}}) + introspection := introspect.NewIntrospectable(&introspect.Node{ + Name: string(moduleObjectPath), Interfaces: []introspect.Interface{spec.introspection(orca)}, + }) + if !orca && spec.name == "SpeechManager" { + registrations[3].value = introspection + } else { + registrations = append(registrations, serviceBinding{moduleObjectPath, "org.freedesktop.DBus.Introspectable", introspection}) + } + children = append(children, introspect.Node{Name: spec.name}) + } + root := &introspect.Node{Name: string(path), Interfaces: []introspect.Interface{{Name: iface, Methods: serviceMethods(orca)}}, Children: children} + registrations[1].value = introspect.NewIntrospectable(root) + if orca { + registrations[0].value = &orcaServiceEndpoint{endpoint: endpoint, modules: sortedModuleNames()} + } + } + return registrations +} diff --git a/internal/remote/service.go b/internal/remote/service.go new file mode 100644 index 0000000..18b9d7b --- /dev/null +++ b/internal/remote/service.go @@ -0,0 +1,322 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +// Package remote publishes Cthulhu's bounded self-voice D-Bus API. +package remote + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/sessionbus" + "github.com/godbus/dbus/v5" +) + +const ( + BusName = "org.stormux.Cthulhu1.Service" + InterfaceName = "org.stormux.Cthulhu1.Service" + ObjectPath = dbus.ObjectPath("/org/stormux/Cthulhu1/Service") + + SpeechManagerInterface = "org.stormux.Cthulhu1.SpeechManager" + SpeechManagerPath = dbus.ObjectPath("/org/stormux/Cthulhu1/Service/SpeechManager") + OrcaBusName = "org.gnome.Orca.Service" + OrcaInterfaceName = "org.gnome.Orca.Service" + OrcaObjectPath = dbus.ObjectPath("/org/gnome/Orca/Service") + + maxTextBytes = 64 * 1024 + maxRequestIDBytes = 256 + authorizeTimeout = 500 * time.Millisecond + + windowsErrorNotSupported int32 = 50 + windowsErrorInvalidParameter int32 = 87 +) + +// Handler accepts already-authorized presentation requests. Implementations +// must return quickly and enqueue backend work rather than delivering it inline. +type Handler interface { + PresentMessage(string) bool + SpeakText(string, bool) bool + BrailleMessage(string) bool + CancelSpeech() bool + PresentAccessibleEvent(AccessibleEvent) bool +} + +// ControlHandler is optional so existing self-voice users keep their contract. +// Implementations enqueue commands with their existing owners and return promptly. +type ControlHandler interface { + ShowPreferences() bool + Quit() bool + RuntimeStateSnapshot() string +} + +// AccessibleEvent is the normalized, bounded Wine helper payload. It contains +// no native object references and is safe to hand to the presentation owner. +type AccessibleEvent struct { + SourceID, EventType, Name, Role, Value, States, Description, WindowTitle string + Position, Count int32 +} + +type connection interface { + Export(any, dbus.ObjectPath, string) error + RequestName(string, dbus.RequestNameFlags) (dbus.RequestNameReply, error) + ReleaseName(string) (dbus.ReleaseNameReply, error) + UnixUser(context.Context, string) (uint32, error) + Close() error +} + +type dbusConnection struct { + *dbus.Conn +} + +func (connection dbusConnection) UnixUser(ctx context.Context, sender string) (uint32, error) { + var uid uint32 + call := connection.BusObject().CallWithContext( + ctx, + "org.freedesktop.DBus.GetConnectionUnixUser", + 0, + sender, + ) + if err := call.Store(&uid); err != nil { + return 0, err + } + return uid, nil +} + +type connector func() (connection, error) + +// Service owns the session-bus connection, exported object, and well-known +// name. The lifecycle owner must call Stop before its Handler is stopped. +type Service struct { + logger *slog.Logger + handler Handler + connect connector + orca bool + + mutex sync.Mutex + connection connection + endpoint *endpoint + ownedName bool + bindings []serviceBinding +} + +type serviceBinding struct { + path dbus.ObjectPath + iface string + value any +} + +// NewOrca creates an independent compatibility service. A running Orca owns +// its name exclusively; a name conflict does not affect the native service. +func NewOrca(logger *slog.Logger, handler Handler) *Service { + service := New(logger, handler) + service.orca = true + return service +} + +func New(logger *slog.Logger, handler Handler) *Service { + return newService(logger, handler, func() (connection, error) { + connection, err := sessionbus.Connect(logger, "self-voice") + if err != nil { + return nil, err + } + return dbusConnection{Conn: connection}, nil + }) +} + +func newService(logger *slog.Logger, handler Handler, connect connector) *Service { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &Service{logger: logger, handler: handler, connect: connect} +} + +func (service *Service) Start(ctx context.Context) error { + if ctx == nil { + return errors.New("remote service start requires a non-nil context") + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("start remote service: %w", err) + } + if service.handler == nil { + return errors.New("start remote service without a handler") + } + + service.mutex.Lock() + defer service.mutex.Unlock() + if service.connection != nil { + return errors.New("remote service already started") + } + connection, err := service.connect() + if err != nil { + return fmt.Errorf("connect to session bus: %w", err) + } + endpoint := newEndpoint(service.logger, service.handler, func(sender dbus.Sender) bool { + authorizeContext, cancel := context.WithTimeout(context.Background(), authorizeTimeout) + defer cancel() + uid, err := connection.UnixUser(authorizeContext, string(sender)) + return err == nil && uid == uint32(os.Getuid()) + }) + + name := BusName + if service.orca { + name = OrcaBusName + } + registrations := service_bindings(endpoint, service.orca, service.handler) + started := false + exported := 0 + defer func() { + if !started { + for index := exported - 1; index >= 0; index-- { + binding := registrations[index] + connection.Export(nil, binding.path, binding.iface) + } + connection.Close() + } + }() + for _, binding := range registrations { + if err := connection.Export(binding.value, binding.path, binding.iface); err != nil { + return fmt.Errorf("export %s: %w", binding.iface, err) + } + exported++ + } + reply, err := connection.RequestName(name, dbus.NameFlagDoNotQueue) + if err != nil { + return fmt.Errorf("request D-Bus name %s: %w", name, err) + } + if reply != dbus.RequestNameReplyPrimaryOwner && reply != dbus.RequestNameReplyAlreadyOwner { + return fmt.Errorf("D-Bus name %s is already owned", name) + } + + endpoint.startAccepting() + service.connection = connection + service.endpoint = endpoint + service.ownedName = true + service.bindings = registrations + if !service.orca { + if handler, ok := service.handler.(WineReviewConnectionHandler); ok { + if native, ok := connection.(dbusConnection); ok { + handler.BindWineReviewConnection(native.Conn) + } + } + } + started = true + service.logger.Debug("remote D-Bus service ready", "name", name) + return nil +} + +func (service *Service) Stop(ctx context.Context) error { + if ctx == nil { + return errors.New("remote service stop requires a non-nil context") + } + service.mutex.Lock() + connection := service.connection + endpoint := service.endpoint + ownedName := service.ownedName + bindings := service.bindings + service.connection = nil + service.endpoint = nil + service.ownedName = false + service.bindings = nil + service.mutex.Unlock() + if connection == nil { + return nil + } + + idle := endpoint.stopAccepting() + if !service.orca { + if handler, ok := service.handler.(WineReviewConnectionHandler); ok { + handler.BindWineReviewConnection(nil) + } + } + var stopErrors []error + name := BusName + if service.orca { + name = OrcaBusName + } + for i := len(bindings) - 1; i >= 0; i-- { + binding := bindings[i] + if err := connection.Export(nil, binding.path, binding.iface); err != nil { + stopErrors = append(stopErrors, fmt.Errorf("unexport %s: %w", binding.iface, err)) + } + } + if ownedName { + if reply, err := connection.ReleaseName(name); err != nil { + stopErrors = append(stopErrors, fmt.Errorf("release D-Bus name %s: %w", name, err)) + } else if reply != dbus.ReleaseNameReplyReleased && reply != dbus.ReleaseNameReplyNonExistent { + stopErrors = append(stopErrors, fmt.Errorf("release D-Bus name %s: reply %d", name, reply)) + } + } + select { + case <-idle: + case <-ctx.Done(): + stopErrors = append(stopErrors, fmt.Errorf("wait for self-voice calls: %w", ctx.Err())) + } + if err := connection.Close(); err != nil { + stopErrors = append(stopErrors, fmt.Errorf("close session bus: %w", err)) + } + return errors.Join(stopErrors...) +} + +// Keep the Orca export limited to the methods it actually advertises. +type orcaServiceEndpoint struct { + endpoint *endpoint + modules []string +} + +func (service *orcaServiceEndpoint) ShowPreferences(sender dbus.Sender) (bool, *dbus.Error) { + return service.endpoint.ShowPreferences(sender) +} + +func (service *orcaServiceEndpoint) PresentMessage(sender dbus.Sender, message string) (bool, *dbus.Error) { + return service.endpoint.PresentMessage(sender, message) +} + +func (service *orcaServiceEndpoint) GetVersion(sender dbus.Sender) (string, *dbus.Error) { + return service.endpoint.GetVersion(sender) +} + +func (service *orcaServiceEndpoint) Quit(sender dbus.Sender) (bool, *dbus.Error) { + return service.endpoint.Quit(sender) +} + +func (service *orcaServiceEndpoint) ListModules(sender dbus.Sender) ([]string, *dbus.Error) { + if err := service.endpoint.begin(sender, "ListModules"); err != nil { + return nil, err + } + defer service.endpoint.end() + return append([]string(nil), service.modules...), nil +} + +func (service *orcaServiceEndpoint) ListCommands(sender dbus.Sender) ([]namedDescription, *dbus.Error) { + if err := service.endpoint.begin(sender, "ListCommands"); err != nil { + return nil, err + } + defer service.endpoint.end() + return []namedDescription{ + {"GetVersion", "Returns Cthulhu's version."}, + {"ListCommands", "Lists available service commands."}, + {"ListModules", "Lists available modules."}, + {"PresentMessage", "Presents a message."}, + {"Quit", "Quits Cthulhu."}, + {"ShowPreferences", "Shows preferences."}, + }, nil +} diff --git a/internal/remote/service_test.go b/internal/remote/service_test.go new file mode 100644 index 0000000..9a0aa9a --- /dev/null +++ b/internal/remote/service_test.go @@ -0,0 +1,324 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. + +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. + +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package remote + +import ( + "context" + "errors" + "log/slog" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +type recordingHandler struct { + mutex sync.Mutex + presented []string + spoken []string + interrupts []bool + canceled int + braille []string + wine []AccessibleEvent + result bool +} + +func (handler *recordingHandler) PresentMessage(message string) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.presented = append(handler.presented, message) + return handler.result +} + +func (handler *recordingHandler) SpeakText(text string, interrupt bool) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.spoken = append(handler.spoken, text) + handler.interrupts = append(handler.interrupts, interrupt) + return handler.result +} + +func (handler *recordingHandler) BrailleMessage(text string) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.braille = append(handler.braille, text) + return false +} + +func (handler *recordingHandler) CancelSpeech() bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.canceled++ + return handler.result +} + +func (handler *recordingHandler) PresentAccessibleEvent(event AccessibleEvent) bool { + handler.mutex.Lock() + defer handler.mutex.Unlock() + handler.wine = append(handler.wine, event) + return handler.result +} + +func authorizedEndpoint(handler Handler) *endpoint { + endpoint := newEndpoint(slog.New(slog.DiscardHandler), handler, func(dbus.Sender) bool { return true }) + endpoint.startAccepting() + return endpoint +} + +type fakeConnection struct { + mutex sync.Mutex + exports []string + requestReply dbus.RequestNameReply + requestError error + releaseReply dbus.ReleaseNameReply + closed bool + failExport string +} + +func (connection *fakeConnection) Export(value any, _ dbus.ObjectPath, iface string) error { + connection.mutex.Lock() + defer connection.mutex.Unlock() + if value != nil && iface == connection.failExport { + return errors.New("simulated export failure") + } + action := "export " + if value == nil { + action = "unexport " + } + connection.exports = append(connection.exports, action+iface) + return nil +} + +func TestServiceExportFailureClosesOnlyItsConnection(t *testing.T) { + failed := &fakeConnection{requestReply: dbus.RequestNameReplyPrimaryOwner, failExport: "org.freedesktop.DBus.Introspectable"} + service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { return failed, nil }) + service.orca = true + if err := service.Start(context.Background()); err == nil { + t.Fatal("export failure was ignored") + } + if !failed.closed { + t.Fatal("failed Orca connection was left open") + } + ready := &fakeConnection{requestReply: dbus.RequestNameReplyPrimaryOwner, releaseReply: dbus.ReleaseNameReplyReleased} + native := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { return ready, nil }) + if err := native.Start(context.Background()); err != nil { + t.Fatalf("native service after Orca export failure: %v", err) + } + if err := native.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} + +func (connection *fakeConnection) RequestName(string, dbus.RequestNameFlags) (dbus.RequestNameReply, error) { + return connection.requestReply, connection.requestError +} + +func (connection *fakeConnection) ReleaseName(string) (dbus.ReleaseNameReply, error) { + return connection.releaseReply, nil +} + +func (connection *fakeConnection) UnixUser(context.Context, string) (uint32, error) { + return uint32(os.Getuid()), nil +} + +func (connection *fakeConnection) Close() error { + connection.closed = true + return nil +} + +func TestServiceOwnsAndReleasesName(t *testing.T) { + fakeBus := &fakeConnection{ + requestReply: dbus.RequestNameReplyPrimaryOwner, + releaseReply: dbus.ReleaseNameReplyReleased, + } + service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { + return fakeBus, nil + }) + if err := service.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if err := service.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + if !fakeBus.closed { + t.Fatal("session bus connection was not closed") + } + want := []string{ + "export " + InterfaceName, + "export org.freedesktop.DBus.Introspectable", + "export " + SpeechManagerInterface, + "export org.freedesktop.DBus.Introspectable", + "unexport org.freedesktop.DBus.Introspectable", + "unexport " + SpeechManagerInterface, + "unexport org.freedesktop.DBus.Introspectable", + "unexport " + InterfaceName, + } + if got := strings.Join(fakeBus.exports, ","); got != strings.Join(want, ",") { + t.Fatalf("exports = %q, want %q", got, strings.Join(want, ",")) + } +} + +func TestNativeSessionBusSelfVoice(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus") + } + handler := &recordingHandler{result: true} + service := New(slog.New(slog.DiscardHandler), handler) + if err := service.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { service.Stop(context.Background()) }) + + conflict := New(slog.New(slog.DiscardHandler), handler) + if err := conflict.Start(context.Background()); err == nil { + conflict.Stop(context.Background()) + t.Fatal("second service acquired the well-known name") + } + + client, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatalf("connect client: %v", err) + } + defer client.Close() + object := client.Object(BusName, ObjectPath) + var accepted bool + if err := object.Call(InterfaceName+".SpeakText", 0, "Native message", false).Store(&accepted); err != nil { + t.Fatalf("SpeakText: %v", err) + } + if !accepted { + t.Fatal("SpeakText was not accepted") + } + if err := object.Call(InterfaceName+".PresentAccessibleEvent", 0, + "42:0:0", "focus", "Start", "button", "", "", "", int32(1), int32(3), "Game").Store(&accepted); err != nil { + t.Fatalf("PresentAccessibleEvent: %v", err) + } + if !accepted { + t.Fatal("PresentAccessibleEvent was not accepted") + } + if err := object.Call(InterfaceName+".BrailleMessage", 0, "Braille").Store(&accepted); err != nil { + t.Fatalf("BrailleMessage: %v", err) + } + if accepted { + t.Fatal("BrailleMessage succeeded without a braille backend") + } + var ssmlResult int32 + if err := object.Call(InterfaceName+".SpeakSsml", 0, "request", "Text").Store(&ssmlResult); err != nil { + t.Fatalf("SpeakSsml: %v", err) + } + if ssmlResult != windowsErrorNotSupported { + t.Fatalf("SpeakSsml = %d, want %d", ssmlResult, windowsErrorNotSupported) + } + var xml string + if err := object.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil { + t.Fatalf("Introspect: %v", err) + } + for _, method := range []string{"PresentMessage", "SpeakText", "BrailleMessage", "CancelSpeech", "SpeakSsml", "PresentAccessibleEvent"} { + if !strings.Contains(xml, `method name="`+method+`"`) { + t.Fatalf("introspection does not contain %s: %s", method, xml) + } + } + + manager := client.Object(BusName, SpeechManagerPath) + for _, notifyUser := range []bool{true, false} { + if err := manager.Call(SpeechManagerInterface+".InterruptSpeech", 0, notifyUser).Store(&accepted); err != nil || !accepted { + t.Fatalf("InterruptSpeech(%v): accepted=%v, error=%v", notifyUser, accepted, err) + } + } + handler.mutex.Lock() + canceled := handler.canceled + handler.mutex.Unlock() + if canceled != 2 { + t.Fatalf("InterruptSpeech cancellations = %d", canceled) + } + if err := manager.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil { + t.Fatal(err) + } + if !strings.Contains(xml, `method name="InterruptSpeech"`) || !strings.Contains(xml, `type="b" direction="in"`) { + t.Fatalf("missing SpeechManager signature: %s", xml) + } + if err := service.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + if err := manager.Call(SpeechManagerInterface+".InterruptSpeech", 0, true).Store(&accepted); err == nil { + t.Fatal("SpeechManager accepted a call after Stop") + } + replacement := New(slog.New(slog.DiscardHandler), handler) + if err := replacement.Start(context.Background()); err != nil { + t.Fatalf("replacement Start after name release: %v", err) + } + if err := replacement.Stop(context.Background()); err != nil { + t.Fatalf("replacement Stop: %v", err) + } +} + +func TestServiceRejectsNameConflictAndCleansUp(t *testing.T) { + fakeBus := &fakeConnection{requestReply: dbus.RequestNameReplyExists} + service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { + return fakeBus, nil + }) + err := service.Start(context.Background()) + if err == nil || !strings.Contains(err.Error(), "already owned") { + t.Fatalf("Start error = %v, want name ownership error", err) + } + if !fakeBus.closed { + t.Fatal("conflicted session bus connection was not closed") + } +} + +func TestServicePropagatesConnectionFailure(t *testing.T) { + want := errors.New("no session bus") + service := newService(slog.New(slog.DiscardHandler), &recordingHandler{}, func() (connection, error) { + return nil, want + }) + if err := service.Start(context.Background()); !errors.Is(err, want) { + t.Fatalf("Start error = %v, want %v", err, want) + } +} + +func TestStopWaitsForActiveCall(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + endpoint := newEndpoint(slog.New(slog.DiscardHandler), &recordingHandler{}, func(dbus.Sender) bool { + close(started) + <-release + return true + }) + endpoint.startAccepting() + done := make(chan struct{}) + go func() { + endpoint.CancelSpeech(dbus.Sender(":1.9")) + close(done) + }() + <-started + idle := endpoint.stopAccepting() + select { + case <-idle: + t.Fatal("endpoint became idle while a call was active") + default: + } + close(release) + select { + case <-idle: + case <-time.After(time.Second): + t.Fatal("endpoint did not become idle") + } + <-done +} diff --git a/internal/remote/wine_review.go b/internal/remote/wine_review.go new file mode 100644 index 0000000..0f650b3 --- /dev/null +++ b/internal/remote/wine_review.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package remote + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/wine" + "github.com/godbus/dbus/v5" +) + +// Optional so legacy handlers and Orca exports retain their existing contract. +type WineReviewHandler interface { + RegisterProvider(string, dbus.ObjectPath, uint32) bool + PresentFocus(string, wine.ReviewFocusNotice) bool +} +type WineReviewConnectionHandler interface{ BindWineReviewConnection(*dbus.Conn) } + +func (e *endpoint) RegisterWineReviewProvider(sender dbus.Sender, path dbus.ObjectPath, version uint32) (bool, *dbus.Error) { + if err := e.begin(sender, "RegisterWineReviewProvider"); err != nil { + return false, err + } + defer e.end() + if handler, ok := e.handler.(WineReviewHandler); ok { + return handler.RegisterProvider(string(sender), path, version), nil + } + return false, nil +} +func (e *endpoint) PresentWineReviewFocus(sender dbus.Sender, instance, window, focus string, revision uint64, title, role string, protected bool, bounds atspi.Rectangle) (bool, *dbus.Error) { + if err := e.begin(sender, "PresentWineReviewFocus"); err != nil { + return false, err + } + defer e.end() + if handler, ok := e.handler.(WineReviewHandler); ok { + return handler.PresentFocus(string(sender), wine.ReviewFocusNotice{Instance: instance, Window: window, Focus: focus, Revision: revision, Title: title, Role: role, Protected: protected, Bounds: bounds}), nil + } + return false, nil +} diff --git a/internal/remote/wine_review_test.go b/internal/remote/wine_review_test.go new file mode 100644 index 0000000..6d40c16 --- /dev/null +++ b/internal/remote/wine_review_test.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package remote + +import ( + "context" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/wine" + "github.com/godbus/dbus/v5" + "os" + "testing" + "time" +) + +type reviewRecordingHandler struct { + *recordingHandler + registry *wine.ReviewRegistry +} + +func (h reviewRecordingHandler) RegisterProvider(sender string, path dbus.ObjectPath, version uint32) bool { + return h.registry.RegisterProvider(sender, path, version) +} +func (h reviewRecordingHandler) PresentFocus(sender string, notice wine.ReviewFocusNotice) bool { + return h.registry.PresentFocus(sender, notice) +} +func (h reviewRecordingHandler) BindWineReviewConnection(conn *dbus.Conn) { + h.registry.BindWineReviewConnection(conn) +} +func TestWineReviewAuthorizationAndOptionalHandler(t *testing.T) { + r := wine.NewReviewRegistry(nil) + endpoint := newEndpoint(nil, reviewRecordingHandler{&recordingHandler{}, r}, func(dbus.Sender) bool { return false }) + endpoint.startAccepting() + if accepted, err := endpoint.RegisterWineReviewProvider(":1.2", wine.ReviewObjectPath, 1); accepted || err == nil || err.Name != "org.freedesktop.DBus.Error.AccessDenied" { + t.Fatal("unauthorized registration accepted") + } + optional := newEndpoint(nil, &recordingHandler{}, func(dbus.Sender) bool { return true }) + optional.startAccepting() + if accepted, err := optional.RegisterWineReviewProvider(":1.2", wine.ReviewObjectPath, 1); accepted || err != nil { + t.Fatal("older handler compatibility broken") + } +} + +type nativeReviewEndpoint struct { + conn *dbus.Conn + calls chan struct{} + bounds atspi.Rectangle +} + +func (f *nativeReviewEndpoint) CaptureWindow(sender dbus.Sender, window, focus string, revision uint64) (map[string]dbus.Variant, *dbus.Error) { + var owner string + if err := f.conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, BusName).Store(&owner); err != nil || owner != string(sender) { + return nil, dbus.NewError("org.freedesktop.DBus.Error.AccessDenied", nil) + } + select { + case f.calls <- struct{}{}: + default: + } + node := map[string]dbus.Variant{"id": dbus.MakeVariant(window), "parent": dbus.MakeVariant(""), "role": dbus.MakeVariant("window"), "name": dbus.MakeVariant("native fixture"), "protected": dbus.MakeVariant(false), "visible": dbus.MakeVariant(true), "bounds": dbus.MakeVariant(f.bounds), "text_known": dbus.MakeVariant(false), "caret_known": dbus.MakeVariant(false), "caret": dbus.MakeVariant(uint64(0)), "caret_utf16": dbus.MakeVariant(uint64(0)), "ranges": dbus.MakeVariant([]wine.ReviewRange{})} + return map[string]dbus.Variant{"version": dbus.MakeVariant(uint32(1)), "instance": dbus.MakeVariant("instance"), "window": dbus.MakeVariant(window), "focus": dbus.MakeVariant(focus), "revision": dbus.MakeVariant(revision), "status": dbus.MakeVariant("ok"), "bounds": dbus.MakeVariant(f.bounds), "nodes": dbus.MakeVariant([]map[string]dbus.Variant{node})}, nil +} +func TestNativeSessionBusWineReview(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("requires private session bus") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + registry := wine.NewReviewRegistry(nil) + if err := registry.Start(ctx); err != nil { + t.Fatal(err) + } + defer registry.Stop(context.Background()) + service := New(nil, reviewRecordingHandler{&recordingHandler{}, registry}) + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + defer service.Stop(context.Background()) + provider, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer provider.Close() + fixture := &nativeReviewEndpoint{conn: provider, calls: make(chan struct{}, 1), bounds: atspi.Rectangle{X: -10, Y: 20, Width: 400, Height: 300}} + if err = provider.Export(fixture, wine.ReviewObjectPath, wine.ReviewInterface); err != nil { + t.Fatal(err) + } + remote := provider.Object(BusName, ObjectPath) + var accepted bool + if err = remote.CallWithContext(ctx, InterfaceName+".RegisterWineReviewProvider", 0, wine.ReviewObjectPath, uint32(1)).Store(&accepted); err != nil || !accepted { + t.Fatalf("registration: %v %v", accepted, err) + } + if err = remote.CallWithContext(ctx, InterfaceName+".PresentWineReviewFocus", 0, "instance", "window", "window", uint64(1), "same title", "window", false, fixture.bounds).Store(&accepted); err != nil || !accepted { + t.Fatalf("focus: %v %v", accepted, err) + } + notices := registry.Focuses() + if len(notices) != 1 { + t.Fatalf("focus mailbox: %+v", notices) + } + id := notices[0].ID() + tree, err := registry.Capture(ctx, id) + if err != nil || tree == nil { + t.Fatalf("actual owner capture: %v", err) + } + wrong, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer wrong.Close() + if err = wrong.Object(id.BusName, wine.ReviewObjectPath).CallWithContext(ctx, wine.ReviewInterface+".CaptureWindow", 0, "window", "window", uint64(1)).Err; err == nil { + t.Fatal("different unique connection accepted") + } + provider.Close() + for registry.Current(id) { + select { + case <-registry.Changes(): + case <-ctx.Done(): + t.Fatal("dead provider remained current") + } + } + if len(registry.Focuses()) != 0 { + t.Fatal("dead focus retained") + } +} diff --git a/internal/review/cursor.go b/internal/review/cursor.go new file mode 100644 index 0000000..8ccc97f --- /dev/null +++ b/internal/review/cursor.go @@ -0,0 +1,198 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +// Package review owns navigation state over immutable AT-SPI snapshots. +package review + +import ( + "errors" + "fmt" + "sync" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +var ( + ErrNoCurrent = errors.New("review cursor has no current object") + ErrInvalidDirection = errors.New("invalid review direction") +) + +type Direction int + +const ( + Parent Direction = iota + 1 + FirstChild + PreviousSibling + NextSibling +) + +func (direction Direction) String() string { + switch direction { + case Parent: + return "parent" + case FirstChild: + return "first-child" + case PreviousSibling: + return "previous-sibling" + case NextSibling: + return "next-sibling" + default: + return fmt.Sprintf("direction(%d)", direction) + } +} + +// Position contains owned snapshots for the cursor's current and previous +// objects. HasPrevious distinguishes an absent previous object from an empty +// snapshot. +type Position struct { + Current atspi.Snapshot + Previous atspi.Snapshot + HasPrevious bool +} + +// Cursor owns object-navigation state over one AccessibleTree. Focus updates +// and navigation may arrive from different goroutines, so Cursor serializes +// its references while AccessibleTree validates their generations. +type Cursor struct { + mu sync.Mutex + tree *atspi.AccessibleTree + current atspi.ObjectReference + previous atspi.ObjectReference + lastFocus atspi.ObjectID +} + +func NewCursor(tree *atspi.AccessibleTree) (*Cursor, error) { + if tree == nil { + return nil, errors.New("create review cursor: nil accessible tree") + } + return &Cursor{tree: tree}, nil +} + +// SyncFocus moves the cursor to focus only when focus changes. Repeated focus +// snapshots therefore do not pull a user back from an independently reviewed +// object. If invalidation made the current reference stale, SyncFocus repairs +// it from the current focus identity when possible. +func (cursor *Cursor) SyncFocus(focus atspi.ObjectID) (Position, error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + + if !focus.Valid() { + return Position{}, errors.New("sync review cursor: invalid focus identity") + } + if focus == cursor.lastFocus && cursor.current.Valid() { + if position, err := cursor.positionLocked(); err == nil { + return position, nil + } else if !errors.Is(err, atspi.ErrStaleObject) { + return Position{}, err + } + } + + reference, err := cursor.tree.Reference(focus) + if err != nil { + return Position{}, fmt.Errorf("sync review cursor: %w", err) + } + cursor.setCurrentLocked(reference) + cursor.lastFocus = focus + return cursor.positionLocked() +} + +func (cursor *Cursor) Current() (Position, error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + return cursor.positionLocked() +} + +// Move traverses one owned-tree relationship. A boundary leaves the cursor in +// place and returns moved=false. Stale references and malformed state are +// errors and also leave the cursor unchanged. +func (cursor *Cursor) Move(direction Direction) (position Position, moved bool, err error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + + if !cursor.current.Valid() { + return Position{}, false, ErrNoCurrent + } + if _, err := cursor.tree.Resolve(cursor.current); err != nil { + return Position{}, false, fmt.Errorf("move review cursor: %w", err) + } + + var next atspi.ObjectReference + var ok bool + switch direction { + case Parent: + next, ok, err = cursor.tree.Parent(cursor.current) + case FirstChild: + next, ok, err = cursor.tree.FirstChild(cursor.current) + case PreviousSibling: + next, ok, err = cursor.tree.PreviousSibling(cursor.current) + case NextSibling: + next, ok, err = cursor.tree.NextSibling(cursor.current) + default: + return Position{}, false, fmt.Errorf("%w: %d", ErrInvalidDirection, direction) + } + if err != nil { + return Position{}, false, fmt.Errorf("move review cursor: %w", err) + } + if !ok { + position, err := cursor.positionLocked() + return position, false, err + } + if _, err := cursor.tree.Resolve(next); err != nil { + return Position{}, false, fmt.Errorf("resolve review destination: %w", err) + } + cursor.setCurrentLocked(next) + position, err = cursor.positionLocked() + return position, true, err +} + +func (cursor *Cursor) Reset() { + cursor.mu.Lock() + defer cursor.mu.Unlock() + cursor.current = atspi.ObjectReference{} + cursor.previous = atspi.ObjectReference{} + cursor.lastFocus = atspi.ObjectID{} +} + +func (cursor *Cursor) setCurrentLocked(reference atspi.ObjectReference) { + if cursor.current.Valid() && cursor.current != reference { + cursor.previous = cursor.current + } else if !cursor.current.Valid() { + cursor.previous = atspi.ObjectReference{} + } + cursor.current = reference +} + +func (cursor *Cursor) positionLocked() (Position, error) { + if !cursor.current.Valid() { + return Position{}, ErrNoCurrent + } + current, err := cursor.tree.Resolve(cursor.current) + if err != nil { + return Position{}, fmt.Errorf("resolve current review object: %w", err) + } + position := Position{Current: current} + if cursor.previous.Valid() { + previous, previousErr := cursor.tree.Resolve(cursor.previous) + if previousErr == nil { + position.Previous = previous + position.HasPrevious = true + } else if !errors.Is(previousErr, atspi.ErrStaleObject) { + return Position{}, fmt.Errorf("resolve previous review object: %w", previousErr) + } + } + return position, nil +} diff --git a/internal/review/cursor_test.go b/internal/review/cursor_test.go new file mode 100644 index 0000000..765087d --- /dev/null +++ b/internal/review/cursor_test.go @@ -0,0 +1,193 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "errors" + "sync" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestDirectionNames(t *testing.T) { + tests := map[Direction]string{ + Parent: "parent", + FirstChild: "first-child", + PreviousSibling: "previous-sibling", + NextSibling: "next-sibling", + Direction(99): "direction(99)", + } + for direction, expected := range tests { + if got := direction.String(); got != expected { + t.Fatalf("direction %d = %q, want %q", direction, got, expected) + } + } +} + +func objectID(path string) atspi.ObjectID { + return atspi.NewObjectID(":1.42", path) +} + +func node(path, name string, children ...atspi.ObjectID) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, "push button", "", "") + snapshot.BusName = ":1.42" + return atspi.TreeNode{Snapshot: snapshot, Children: children} +} + +func newTree(t *testing.T) *atspi.AccessibleTree { + t.Helper() + tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{ + node("/root", "Window", objectID("/first"), objectID("/second")), + node("/first", "First"), + node("/second", "Second", objectID("/grandchild")), + node("/grandchild", "Grandchild"), + }) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestCursorFollowsChangedFocusAndPreservesIndependentNavigation(t *testing.T) { + cursor, err := NewCursor(newTree(t)) + if err != nil { + t.Fatal(err) + } + position, err := cursor.SyncFocus(objectID("/first")) + if err != nil || position.Current.Name != "First" || position.HasPrevious { + t.Fatalf("initial position = %#v, error = %v", position, err) + } + + position, moved, err := cursor.Move(NextSibling) + if err != nil || !moved || position.Current.Name != "Second" || !position.HasPrevious || position.Previous.Name != "First" { + t.Fatalf("next position = %#v, moved = %v, error = %v", position, moved, err) + } + position, err = cursor.SyncFocus(objectID("/first")) + if err != nil || position.Current.Name != "Second" { + t.Fatalf("repeated focus pulled cursor back: position = %#v, error = %v", position, err) + } + + position, err = cursor.SyncFocus(objectID("/grandchild")) + if err != nil || position.Current.Name != "Grandchild" || !position.HasPrevious || position.Previous.Name != "Second" { + t.Fatalf("changed focus position = %#v, error = %v", position, err) + } +} + +func TestCursorTraversesRelationshipsAndPreservesBoundaries(t *testing.T) { + cursor, _ := NewCursor(newTree(t)) + if _, err := cursor.SyncFocus(objectID("/second")); err != nil { + t.Fatal(err) + } + + position, moved, err := cursor.Move(FirstChild) + if err != nil || !moved || position.Current.Name != "Grandchild" { + t.Fatalf("child position = %#v, moved = %v, error = %v", position, moved, err) + } + position, moved, err = cursor.Move(FirstChild) + if err != nil || moved || position.Current.Name != "Grandchild" { + t.Fatalf("child boundary = %#v, moved = %v, error = %v", position, moved, err) + } + position, moved, err = cursor.Move(Parent) + if err != nil || !moved || position.Current.Name != "Second" { + t.Fatalf("parent position = %#v, moved = %v, error = %v", position, moved, err) + } + position, moved, err = cursor.Move(PreviousSibling) + if err != nil || !moved || position.Current.Name != "First" { + t.Fatalf("previous position = %#v, moved = %v, error = %v", position, moved, err) + } + position, moved, err = cursor.Move(PreviousSibling) + if err != nil || moved || position.Current.Name != "First" { + t.Fatalf("previous boundary = %#v, moved = %v, error = %v", position, moved, err) + } +} + +func TestCursorRepairsStaleCurrentFromUnchangedFocus(t *testing.T) { + tree := newTree(t) + cursor, _ := NewCursor(tree) + if _, err := cursor.SyncFocus(objectID("/first")); err != nil { + t.Fatal(err) + } + tree.Invalidate(objectID("/first")) + if _, err := cursor.Current(); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatalf("current error = %v, want stale object", err) + } + if err := tree.Replace(objectID("/root"), []atspi.TreeNode{ + node("/root", "Window", objectID("/first")), + node("/first", "Reintroduced"), + }); err != nil { + t.Fatal(err) + } + position, err := cursor.SyncFocus(objectID("/first")) + if err != nil || position.Current.Name != "Reintroduced" { + t.Fatalf("repaired position = %#v, error = %v", position, err) + } +} + +func TestCursorErrorsDoNotMoveAndResetClearsState(t *testing.T) { + cursor, _ := NewCursor(newTree(t)) + if _, _, err := cursor.Move(Parent); !errors.Is(err, ErrNoCurrent) { + t.Fatalf("empty move error = %v", err) + } + if _, err := cursor.SyncFocus(atspi.ObjectID{}); err == nil { + t.Fatal("invalid focus was accepted") + } + if _, err := cursor.SyncFocus(objectID("/first")); err != nil { + t.Fatal(err) + } + if _, _, err := cursor.Move(Direction(99)); !errors.Is(err, ErrInvalidDirection) { + t.Fatalf("invalid direction error = %v", err) + } + position, err := cursor.Current() + if err != nil || position.Current.Name != "First" { + t.Fatalf("position after invalid move = %#v, error = %v", position, err) + } + cursor.Reset() + if _, err := cursor.Current(); !errors.Is(err, ErrNoCurrent) { + t.Fatalf("reset current error = %v", err) + } +} + +func TestCursorSerializesFocusAndNavigation(t *testing.T) { + cursor, _ := NewCursor(newTree(t)) + if _, err := cursor.SyncFocus(objectID("/first")); err != nil { + t.Fatal(err) + } + var wait sync.WaitGroup + for index := 0; index < 20; index++ { + wait.Add(2) + go func() { + defer wait.Done() + _, _ = cursor.SyncFocus(objectID("/first")) + }() + go func() { + defer wait.Done() + _, _, _ = cursor.Move(NextSibling) + }() + } + wait.Wait() + if _, err := cursor.Current(); err != nil { + t.Fatal(err) + } +} + +func TestNewCursorRejectsNilTree(t *testing.T) { + if _, err := NewCursor(nil); err == nil { + t.Fatal("nil tree was accepted") + } +} diff --git a/internal/review/document.go b/internal/review/document.go new file mode 100644 index 0000000..d4b4eff --- /dev/null +++ b/internal/review/document.go @@ -0,0 +1,609 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "strings" + "sync" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// NavigationOrigin distinguishes native focus from document navigation for +// the future browse/focus-mode policy. Flat review never changes this cursor. +type NavigationOrigin uint8 + +const ( + NativeNavigation NavigationOrigin = iota + StructuralNavigation + CaretNavigation +) + +type DocumentPosition struct { + Document atspi.ObjectReference + Object atspi.ObjectReference + Snapshot atspi.Snapshot + // LinkedHeading is the link associated with a heading target, if any. + LinkedHeading *atspi.Snapshot + Origin NavigationOrigin + Offset int +} + +// DocumentMoveResult reports whether a semantic move crossed a document +// boundary and wrapped to the opposite end. +type DocumentMoveResult struct { + Position DocumentPosition + Moved bool + Wrapped bool +} + +type documentPlace struct { + document atspi.ObjectReference + object atspi.ObjectReference + focus atspi.ObjectReference + origin NavigationOrigin + offset int + tableCell atspi.ObjectID + tableRow, tableColumn int +} + +// DocumentCursor remembers at most 16 document positions in least-recently- +// used order. Leaving a document suspends navigation without losing its place; +// invalidated/reused identities cannot inherit it. The caller owns the tree's +// capture/recapture lifecycle. Methods serialize cursor state and do no native +// work, key handling, focus changes, or speech. +type DocumentCursor struct { + mu sync.Mutex + tree *atspi.AccessibleTree + places []documentPlace + active atspi.ObjectReference +} + +// rebindPlace keeps the selected target after a semantic refresh retires its +// old tree references between a move and native caret placement. +func (cursor *DocumentCursor) rebindPlace(tree *atspi.AccessibleTree, oldPosition, newPosition DocumentPosition) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + if cursor.tree != tree || len(cursor.places) == 0 { + return + } + place := &cursor.places[len(cursor.places)-1] + if place.document != oldPosition.Document || place.object != oldPosition.Object { + return + } + place.document = newPosition.Document + place.object = newPosition.Object + if focus, err := tree.Reference(place.focus.ID()); err == nil { + place.focus = focus + } + cursor.active = newPosition.Document +} + +func NewDocumentCursor(tree *atspi.AccessibleTree) (*DocumentCursor, error) { + if tree == nil { + return nil, errors.New("create document cursor: nil accessible tree") + } + return &DocumentCursor{tree: tree}, nil +} + +// ActivationTarget resolves the current browse position, including text +// children inside a link, without consulting the browser's retained focus. +func (cursor *DocumentCursor) ActivationTarget() (atspi.ObjectReference, error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + if !cursor.active.Valid() || len(cursor.places) == 0 { + return atspi.ObjectReference{}, ErrNoCurrent + } + place := cursor.places[len(cursor.places)-1] + if place.document != cursor.active { + return atspi.ObjectReference{}, ErrNoCurrent + } + current := place.object + var clickable atspi.ObjectReference + selectedHeading := false + for depth := 0; depth < 128; depth++ { + snapshot, err := cursor.tree.Resolve(current) + if err != nil { + return atspi.ObjectReference{}, err + } + if depth == 0 { + selectedHeading = snapshot.Role == "heading" + } + if snapshot.Role == "link" { + return current, nil + } + if !clickable.Valid() && snapshot.SupportsClick && + snapshot.Role != "document web" && snapshot.Role != "document frame" { + clickable = current + } + if current == place.document { + break + } + parent, ok, err := cursor.tree.Parent(current) + if err != nil { + return atspi.ObjectReference{}, err + } + if !ok { + break + } + current = parent + } + if place.origin == StructuralNavigation && selectedHeading { + doc, err := cursor.tree.Document(context.Background(), place.object) + if err != nil { + return atspi.ObjectReference{}, err + } + link, ok, ambiguous := linkedHeadingNode(doc, place.object) + if ok { + return link.Reference, nil + } + if ambiguous { + return atspi.ObjectReference{}, ErrNoCurrent + } + } + if clickable.Valid() { + return clickable, nil + } + return atspi.ObjectReference{}, ErrNoCurrent +} + +func (cursor *DocumentCursor) SyncFocus(ctx context.Context, focus atspi.ObjectID) (DocumentPosition, error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + // Failed ownership resolution must never leave web navigation active for + // the previous focus (e.g. address bar, another application, stale object). + cursor.active = atspi.ObjectReference{} + ref, err := cursor.tree.Reference(focus) + if err != nil { + return DocumentPosition{}, err + } + doc, err := cursor.tree.Document(ctx, ref) + if err != nil { + return DocumentPosition{}, err + } + place := documentPlace{document: doc.Context.TopLevel, object: ref, focus: ref} + for index, saved := range cursor.places { + if saved.document != place.document { + continue + } + if saved.focus == ref || saved.object == ref { + if _, ok := documentObject(doc, saved.object); ok { + place = saved + place.focus = ref + } + } + cursor.places = append(cursor.places[:index], cursor.places[index+1:]...) + break + } + if len(cursor.places) == 16 { + cursor.places = cursor.places[1:] + } + cursor.places = append(cursor.places, place) + cursor.active = place.document + return documentPosition(doc, place), nil +} + +// JumpToFragment follows a browser document load whose URI names an element. +// The browser has already scrolled to that element; keep the independent +// browse cursor with the first named link in its subtree when available. +func (cursor *DocumentCursor) JumpToFragment(ctx context.Context, fragment string) (DocumentPosition, bool, error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + if !cursor.active.Valid() || len(cursor.places) == 0 || fragment == "" { + return DocumentPosition{}, false, ErrNoCurrent + } + place := &cursor.places[len(cursor.places)-1] + doc, err := cursor.tree.Document(ctx, place.object) + if err != nil { + return DocumentPosition{}, false, err + } + indexByID := make(map[atspi.ObjectID]int, len(doc.Nodes)) + for index, node := range doc.Nodes { + indexByID[node.Reference.ID()] = index + } + var target *atspi.DocumentNode + for index := range doc.Nodes { + if doc.Nodes[index].Snapshot.ElementID != fragment || doc.Nodes[index].Hidden { + continue + } + root := &doc.Nodes[index] + target = root + var namedDescendant *atspi.DocumentNode + pending := append([]atspi.ObjectID(nil), root.Children...) + for len(pending) > 0 { + id := pending[0] + pending = pending[1:] + childIndex, ok := indexByID[id] + if !ok { + continue + } + child := &doc.Nodes[childIndex] + if child.Hidden { + continue + } + if namedDescendant == nil && child.Snapshot.Name != "" { + namedDescendant = child + } + if child.Snapshot.Role == "link" && child.Snapshot.Name != "" { + target = child + break + } + pending = append(append([]atspi.ObjectID(nil), child.Children...), pending...) + } + if target == root && namedDescendant != nil { + target = namedDescendant + } + break + } + if target == nil { + return DocumentPosition{}, false, nil + } + place.object, place.offset, place.origin = target.Reference, 0, CaretNavigation + return documentPosition(doc, *place), true, nil +} + +type DocumentTarget uint8 + +const ( + DocumentHeading DocumentTarget = iota + 1 + DocumentLink + DocumentButton + DocumentCheckBox + DocumentList + DocumentSeparator + DocumentBlockQuote + DocumentComboBox + DocumentEntry + DocumentFormField + DocumentImage + DocumentLandmark + DocumentListItem + DocumentLiveRegion + DocumentParagraph + DocumentRadioButton + DocumentTable + DocumentUnvisitedLink + DocumentVisitedLink + DocumentClickable + DocumentChunk +) + +// Move searches in accessible tree order, wrapping once to the opposite end. +// Heading level zero means any level. This is a semantic traversal primitive, +// not yet the full Python structural-navigation policy or a native caret/focus +// operation. +func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, level int, backwards bool) (DocumentMoveResult, error) { + cursor.mu.Lock() + defer cursor.mu.Unlock() + if !validDocumentTarget(target) || level < 0 || level > 6 || target != DocumentHeading && level != 0 { + return DocumentMoveResult{}, errors.New("invalid document navigation target") + } + if !cursor.active.Valid() || len(cursor.places) == 0 { + return DocumentMoveResult{}, ErrNoCurrent + } + place := &cursor.places[len(cursor.places)-1] + doc, err := cursor.tree.Document(ctx, place.object) + if err != nil { + return DocumentMoveResult{}, err + } + // Reparenting a live object into a different document must not transfer + // the old document's cursor, even when the object's ID remains live. + if doc.Context.TopLevel != place.document { + return DocumentMoveResult{}, atspi.ErrStaleObject + } + index := -1 + for i, node := range doc.Nodes { + if node.Reference == place.object { + index = i + break + } + } + if index < 0 { + return DocumentMoveResult{}, atspi.ErrStaleObject + } + // Python treats a focused descendant as being within its nearest matching + // ancestor. Anchor the search there so previous navigation does not select + // and reannounce the containing link, heading, or other target. + indexByID := make(map[atspi.ObjectID]int, len(doc.Nodes)) + parentByID := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes)) + for candidateIndex, node := range doc.Nodes { + indexByID[node.Reference.ID()] = candidateIndex + for _, child := range node.Children { + parentByID[child] = node.Reference.ID() + } + } + for parent := place.object.ID(); parent.Valid(); parent = parentByID[parent] { + candidateIndex, ok := indexByID[parent] + if !ok { + break + } + if documentTargetMatches(target, doc.Nodes[candidateIndex], level) { + index = candidateIndex + break + } + } + step := 1 + if backwards { + step = -1 + } + for i := index + step; i >= 0 && i < len(doc.Nodes); i += step { + node := doc.Nodes[i] + if node.Hidden { + continue + } + match := documentTargetMatches(target, node, level) + if match { + place.tableCell = atspi.ObjectID{} + place.object = node.Reference + place.origin = StructuralNavigation + place.offset = 0 + return DocumentMoveResult{Position: documentPosition(doc, *place), Moved: true}, nil + } + } + firstMatch, lastMatch := -1, -1 + for i := 0; i < len(doc.Nodes); i++ { + node := doc.Nodes[i] + if node.Hidden { + continue + } + if !documentTargetMatches(target, node, level) { + continue + } + if firstMatch < 0 { + firstMatch = i + } + lastMatch = i + } + wrappedIndex := firstMatch + if backwards { + wrappedIndex = lastMatch + } + if wrappedIndex >= 0 { + node := doc.Nodes[wrappedIndex] + place.tableCell = atspi.ObjectID{} + place.object = node.Reference + place.origin = StructuralNavigation + place.offset = 0 + return DocumentMoveResult{ + Position: documentPosition(doc, *place), + Moved: true, + Wrapped: true, + }, nil + } + return DocumentMoveResult{Position: documentPosition(doc, *place)}, nil +} + +func validDocumentTarget(target DocumentTarget) bool { + return target >= DocumentHeading && target <= DocumentChunk +} + +func documentTargetMatches(target DocumentTarget, node atspi.DocumentNode, level int) bool { + snapshot := node.Snapshot + switch target { + case DocumentHeading: + return snapshot.Role == "heading" && (level == 0 || snapshot.HeadingLevel == level) + case DocumentLink: + return snapshot.Role == "link" && snapshot.Focusable + case DocumentButton: + return snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" + case DocumentCheckBox: + return snapshot.Role == "check box" + case DocumentList: + return snapshot.Role == "list" + case DocumentSeparator: + return snapshot.Role == "separator" + case DocumentBlockQuote: + return snapshot.Role == "block quote" + case DocumentComboBox: + return snapshot.Role == "combo box" + case DocumentEntry: + return snapshot.Editable && snapshot.Focusable && !node.ParentEditable + case DocumentFormField: + return snapshot.Focusable && documentFormFieldRole(snapshot) + case DocumentImage: + return snapshot.Role == "image" || snapshot.Role == "image map" + case DocumentLandmark: + return snapshot.Role == "landmark" + case DocumentListItem: + return snapshot.Role == "list item" + case DocumentLiveRegion: + return snapshot.LiveRegion + case DocumentParagraph: + if snapshot.Role == "heading" { + return true + } + return snapshot.Role == "paragraph" && snapshot.CharacterCount > 2 + case DocumentRadioButton: + return snapshot.Role == "radio button" + case DocumentTable: + return snapshot.Role == "table" && len(node.Children) != 0 && snapshot.TableRowCount > 0 && !snapshot.LayoutTable + case DocumentUnvisitedLink: + return snapshot.Role == "link" && snapshot.Focusable && !snapshot.Visited + case DocumentVisitedLink: + return snapshot.Role == "link" && snapshot.Focusable && snapshot.Visited + case DocumentClickable: + return snapshot.SupportsClick && !snapshot.Focusable && !documentClickExcludedRole(snapshot) && !documentHasXMLRole(snapshot, "gridcell") + case DocumentChunk: + if documentChunkRole(snapshot) { + return snapshot.Role == "heading" || snapshot.Role == "list" || snapshot.Role == "table" || + (snapshot.CharacterCount > 75 && snapshot.EmbeddedObjectCount*20 < snapshot.CharacterCount) + } + return false + default: + return false + } +} + +func documentHasXMLRole(snapshot atspi.Snapshot, role string) bool { + for _, candidate := range strings.Fields(snapshot.XMLRoles) { + if candidate == role { + return true + } + } + return false +} + +func documentFormFieldRole(snapshot atspi.Snapshot) bool { + switch snapshot.Role { + case "button", "push button", "check box", "radio button", "combo box", "list box", "entry", "password text", "spin button", "toggle button": + return true + case "document frame", "text": + if snapshot.Role == "document frame" { + return snapshot.Editable + } + return true + default: + return false + } +} + +func documentClickExcludedRole(snapshot atspi.Snapshot) bool { + switch snapshot.Role { + case "combo box", "entry", "list box", "menu", "menu item", "check menu item", "radio menu item", "page tab", "page tab list", "password text", "progress bar", "slider", "spin button", "tool bar", "tree item", "tree table", "tree": + return true + default: + return false + } +} + +func documentChunkRole(snapshot atspi.Snapshot) bool { + switch snapshot.Role { + case "heading", "list item", "math", "paragraph", "static", "column header", "row header", "table cell", "table row", "text", "section", "article", "description term", "description value", "document email", "document frame", "document presentation", "document spreadsheet", "document text", "document web", "block quote", "description list", "form", "footer", "header", "landmark", "log", "list", "marquee", "panel", "table", "tree", "tree table": + return true + default: + return false + } +} + +func documentObject(doc atspi.DocumentSnapshot, ref atspi.ObjectReference) (atspi.Snapshot, bool) { + for _, node := range doc.Nodes { + if node.Reference == ref { + return node.Snapshot, true + } + } + return atspi.Snapshot{}, false +} + +func documentPosition(doc atspi.DocumentSnapshot, place documentPlace) DocumentPosition { + snapshot, _ := documentObject(doc, place.object) + position := DocumentPosition{Document: place.document, Object: place.object, Snapshot: snapshot, Origin: place.origin, Offset: place.offset} + if place.origin == StructuralNavigation && snapshot.Role == "heading" { + if link, ok, _ := linkedHeadingNode(doc, place.object); ok { + position.LinkedHeading = &link.Snapshot + } + } + return position +} + +// A heading inside a link and a heading containing exactly one link have the +// same activation target. With multiple links, use the link at the first +// readable caret position, matching Python's heading navigation. A heading +// starting with plain text has no unambiguous link target. +func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference) (atspi.DocumentNode, bool, bool) { + byID := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) + parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes)) + for _, node := range doc.Nodes { + byID[node.Reference.ID()] = node + for _, child := range node.Children { + parents[child] = node.Reference.ID() + } + } + root, ok := byID[heading.ID()] + if !ok || root.Snapshot.Role != "heading" { + return atspi.DocumentNode{}, false, false + } + for parent := parents[heading.ID()]; parent.Valid(); parent = parents[parent] { + node, found := byID[parent] + if !found || atspi.IsWebDocumentRole(node.Snapshot.Role) { + break + } + if node.Snapshot.Role == "link" { + return node, true, false + } + } + var match atspi.DocumentNode + count := 0 + pending := append([]atspi.ObjectID(nil), root.Children...) + for len(pending) != 0 && count < 2 { + id := pending[len(pending)-1] + pending = pending[:len(pending)-1] + node, found := byID[id] + if !found || node.Hidden || node.Snapshot.Role == "heading" { + continue + } + if node.Snapshot.Role == "link" { + match, count = node, count+1 + continue + } + pending = append(pending, node.Children...) + } + if count > 1 { + // Semantic captures need not include text offsets (notably Gecko's + // link leaves). Use readable content and provider child order here. + pending = append([]atspi.ObjectID(nil), root.Children...) + for left, right := 0, len(pending)-1; left < right; left, right = left+1, right-1 { + pending[left], pending[right] = pending[right], pending[left] + } + for len(pending) != 0 { + id := pending[len(pending)-1] + pending = pending[:len(pending)-1] + node, found := byID[id] + if !found || node.Hidden { + continue + } + if node.Snapshot.Role == "heading" || atspi.IsWebDocumentRole(node.Snapshot.Role) { + break + } + readable := strings.TrimSpace(node.Snapshot.Name+node.Snapshot.Text) != "" || node.Snapshot.CharacterCount > 0 + if !wholeCaretNode(node.Snapshot) && !(readable && (len(node.Children) == 0 || node.Snapshot.Role == "link")) { + for child := len(node.Children) - 1; child >= 0; child-- { + pending = append(pending, node.Children[child]) + } + continue + } + var firstLink atspi.DocumentNode + for ; id.Valid(); id = parents[id] { + if id == heading.ID() { + if firstLink.Reference.Valid() { + // Gecko may omit plain text between child accessibles. + // A named first link must also start the heading label. + headingName := strings.TrimSpace(root.Snapshot.Name) + linkName := strings.TrimSpace(firstLink.Snapshot.Name) + if headingName != "" && linkName != "" && !strings.HasPrefix(headingName, linkName) { + break + } + return firstLink, true, false + } + break + } + node := byID[id] + if node.Snapshot.Role == "heading" || atspi.IsWebDocumentRole(node.Snapshot.Role) { + break + } + if node.Snapshot.Role == "link" && !firstLink.Reference.Valid() { + firstLink = node + } + } + // Only the heading's starting caret position can select a link. + break + } + } + return match, count == 1, count > 1 +} diff --git a/internal/review/document_activation_test.go b/internal/review/document_activation_test.go new file mode 100644 index 0000000..3ee2e35 --- /dev/null +++ b/internal/review/document_activation_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type refreshingActivator struct { + *readAllCapturer + target atspi.ObjectID + staleOnce bool +} + +func (activator *refreshingActivator) ActivateDocumentObject(_ context.Context, target atspi.ObjectReference, _ atspi.ObjectID) error { + if activator.staleOnce { + activator.staleOnce = false + return atspi.ErrStaleObject + } + activator.target = target.ID() + return nil +} + +func activationTree(t *testing.T, includeSelected bool) *atspi.AccessibleTree { + t.Helper() + root := atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/focused")}} + nodes := []atspi.TreeNode{root, {Snapshot: atspi.Snapshot{BusName: webID("/focused").BusName, Path: "/focused", Role: "link"}}} + if includeSelected { + nodes[0].Children = append(nodes[0].Children, webID("/selected")) + nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID("/selected").BusName, Path: "/selected", Role: "link"}}) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestActivationRecoversUncapturedAndStaleCursors(t *testing.T) { + for _, kind := range []string{"uncaptured", "stale", "removed", "native-stale"} { + t.Run(kind, func(t *testing.T) { + fresh := activationTree(t, kind != "removed") + activator := &refreshingActivator{readAllCapturer: &readAllCapturer{tree: fresh}, staleOnce: kind == "native-stale"} + worker, _ := NewWorker(activator, time.Second) + worker.SyncFocus(webID("/focused")) + var cursor *DocumentCursor + if kind != "uncaptured" { + old := activationTree(t, true) + cursor, _ = NewDocumentCursor(old) + cursor.SyncFocus(context.Background(), webID("/focused")) + cursor.places[0].object, _ = old.Reference(webID("/selected")) + if kind != "native-stale" { + old.Invalidate(webID("/selected")) + } + } + result, updated := worker.activateDocumentCaret(context.Background(), cursor, 0) + if kind == "removed" { + if result.Err == nil || activator.target.Valid() { + t.Fatal("activated a different link after selected link disappeared") + } + } else { + want := webID("/selected") + if kind == "uncaptured" { + want = webID("/focused") + } + if result.Err != nil || activator.target != want { + t.Fatalf("activation = %v, %v; want %v", activator.target, result.Err, want) + } + if current, err := updated.ActivationTarget(); err != nil || current.ID() != want { + t.Fatalf("cursor lost after activation: %v, %v", current, err) + } + } + }) + } +} diff --git a/internal/review/document_braille.go b/internal/review/document_braille.go new file mode 100644 index 0000000..b67220c --- /dev/null +++ b/internal/review/document_braille.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// readDocumentBraille reads the accepted line and selection ranges for its +// inline fragments. Optional display reads never fail or repeat navigation. +func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Result { + if !worker.documentBraille.Load() || !result.Moved || result.Err != nil || result.CaretErr != nil || + !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus || + result.DocumentCaretCommand >= TableLeft || result.DocumentPosition.Snapshot.Role == "password text" { + return result + } + // Explicitly named links are atomic browse objects. Their native text may + // contain only a numeric badge; braille must use the same accessible label + // as speech rather than enriching it with that incomplete child text. + if snapshot := result.DocumentPosition.Snapshot; snapshot.Role == "link" && wholeCaretNode(snapshot) { + return result + } + reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(DocumentCaretReader) + if !ok || !result.DocumentPosition.Object.Valid() { + return result + } + // Display enrichment must not hold ordinary speech/navigation for the + // longer whole-document capture timeout. + deadline, cancel := context.WithTimeout(ctx, min(worker.captureTimeout, 100*time.Millisecond)) + defer cancel() + line, err := reader.ReadDocumentCaret(deadline, result.DocumentPosition.Object, result.DocumentPosition.Offset, atspi.DocumentCaretBrailleLine) + if err != nil { + result.DocumentBrailleErr = err + return result + } + if line.Start < 0 || result.DocumentPosition.Offset < line.Start || + result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(line.Text) || line.Text == "" { + result.DocumentBrailleErr = atspi.ErrNoDocumentCaret + return result + } + result.DocumentBrailleLine = &line + if assembled := result.DocumentCaretLine; assembled != nil { + copyLine := *assembled + copyLine.Contents = append([]atspi.DocumentContent(nil), assembled.Contents...) + for index := range copyLine.Contents { + content := ©Line.Contents[index] + content.Snapshot.SelectionStart, content.Snapshot.SelectionEnd = 0, 0 + selected := line + if content.Snapshot.ID() != result.DocumentPosition.Object.ID() { + if !content.Reference.Valid() { + continue + } + selected, err = reader.ReadDocumentCaret(deadline, content.Reference, content.Start, atspi.DocumentCaretBrailleLine) + if err != nil { + continue + } + } + start, end := content.Start-selected.Start, content.Start-selected.Start+utf8.RuneCountInString(content.RawText) + runes := []rune(selected.Text) + if start < 0 || end > len(runes) || string(runes[start:end]) != content.RawText { + continue + } + content.Snapshot.SelectionStart, content.Snapshot.SelectionEnd = selected.SelectionStart, selected.SelectionEnd + content.Snapshot.TextAttributeRuns = selected.TextAttributeRuns + } + result.DocumentCaretLine = ©Line + } + return result +} diff --git a/internal/review/document_braille_routing.go b/internal/review/document_braille_routing.go new file mode 100644 index 0000000..8e8143c --- /dev/null +++ b/internal/review/document_braille_routing.go @@ -0,0 +1,133 @@ +package review + +import ( + "context" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type documentBrailleRequest struct { + ctx context.Context + focus, document, target atspi.ObjectID + line string + start, offset int + done chan error +} + +// RouteDocumentBraille shares the navigation FIFO. The display session may +// wait, but input and event callbacks never wait for native caret placement. +func (worker *Worker) RouteDocumentBraille(ctx context.Context, epoch uint64, focus, document, target atspi.ObjectID, line string, start, offset int) error { + if err := ctx.Err(); err != nil { + return err + } + route := &documentBrailleRequest{ctx: ctx, focus: focus, document: document, target: target, line: line, start: start, offset: offset, done: make(chan error, 1)} + select { + case worker.requests <- request{brailleRoute: route, documentEpoch: epoch, queuedAt: time.Now()}: + default: + return ErrRequestQueueFull + } + select { + case err := <-route.done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (worker *Worker) route_document_braille(ctx context.Context, cursor *DocumentCursor, request request) (result Result) { + route := request.brailleRoute + result = Result{DocumentBrailleRoute: true, DocumentCaretCommand: CaretReadLine, DocumentFocus: route.focus, DocumentEpoch: request.documentEpoch} + defer func() { route.done <- result.Err }() + if cursor == nil || worker.focus() != route.focus || !worker.CurrentDocumentResult(result) { + result.Err = atspi.ErrStaleObject + return + } + ctx, cancel := context.WithTimeout(ctx, worker.captureTimeout) + stop := context.AfterFunc(route.ctx, cancel) + defer stop() + defer cancel() + if result.Err = route.ctx.Err(); result.Err != nil { + return + } + target, err := cursor.tree.Reference(route.target) + if err != nil { + result.Err = err + return + } + owner, err := cursor.tree.DocumentContext(target) + if err != nil || owner.TopLevel.ID() != route.document { + result.Err = atspi.ErrStaleObject + return + } + router, ok := worker.nativeCapturerFor(route.focus).(interface { + RouteBraille(context.Context, atspi.BrailleRoute) error + }) + if !ok { + result.Err = atspi.ErrNoDocumentCaret + return + } + reader, ok := worker.nativeCapturerFor(route.focus).(DocumentCaretReader) + if !ok { + result.Err = atspi.ErrNoDocumentCaret + return + } + doc, err := cursor.tree.Document(ctx, target) + if err != nil { + result.Err = err + return + } + node, err := native_caret_node(ctx, reader, doc, route.target, route.offset) + if err != nil { + result.Err = err + return + } + if node.Hidden { + result.Err = atspi.ErrStaleObject + return + } + result.Err = router.RouteBraille(ctx, atspi.BrailleRoute{Focus: route.focus, Position: target, Line: route.line, Start: route.start, Offset: route.offset}) + if result.Err != nil || worker.focus() != route.focus || !worker.CurrentDocumentResult(result) { + if result.Err == nil { + result.Err = atspi.ErrStaleObject + } + return + } + // Keep the document anchor; a routed inline child is not native focus. + cursor.mu.Lock() + if len(cursor.places) == 0 || cursor.active.ID() != route.document { + cursor.mu.Unlock() + result.Err = atspi.ErrStaleObject + return + } + place := &cursor.places[len(cursor.places)-1] + place.object, place.offset, place.origin = node.Reference, route.offset, CaretNavigation + place.tableCell = atspi.ObjectID{} + snapshot, err := cursor.tree.Resolve(node.Reference) + result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: route.offset, Origin: CaretNavigation} + cursor.mu.Unlock() + result.Err, result.Moved = err, err == nil + result.DocumentCaretContext = owner.Ancestors + if result.Moved && worker.documentBraille.Load() { + // Optional assembly refresh preserves surrounding inline fragments while + // retaining the exact routed offset. It never repeats caret placement. + deadline, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + indices, _ := caret_node_indices(doc.Nodes, node.Reference) + position := -1 + for index, candidate := range indices { + if doc.Nodes[candidate].Reference == node.Reference { + position = index + break + } + } + if position >= 0 { + parts, err := collect_visual_line(deadline, reader, doc, indices, visual_line_owners(doc.Nodes), position, route.offset, true) + if err == nil && len(parts) > 1 { + line, _, _ := assemble_visual_line(cursor.tree, doc, parts) + result.DocumentCaretLine = &line + } + } + } + return +} diff --git a/internal/review/document_braille_routing_test.go b/internal/review/document_braille_routing_test.go new file mode 100644 index 0000000..c5e8505 --- /dev/null +++ b/internal/review/document_braille_routing_test.go @@ -0,0 +1,91 @@ +package review + +import ( + "context" + "errors" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type brailleDocumentNavigator struct { + *boundaryNavigator + routes chan atspi.BrailleRoute + routeErr error +} + +func (navigator *brailleDocumentNavigator) RouteBraille(ctx context.Context, route atspi.BrailleRoute) error { + if err := ctx.Err(); err != nil { + return err + } + navigator.routes <- route + return navigator.routeErr +} + +func TestDocumentBrailleRoutingUpdatesBrowseCursorAndRejectsStaleRequests(t *testing.T) { + text := caretTextReader{webID("/text"): "é猫 word", webID("/next"): "Next line"} + navigator := &brailleDocumentNavigator{boundaryNavigator: &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text}, routes: make(chan atspi.BrailleRoute, 4)} + worker, cancel := startWorker(t, navigator, time.Second) + defer cancel() + worker.SetDocumentBrailleEnabled(true) + worker.SyncFocus(webID("/text")) + if err := worker.MoveDocumentCaret(CaretDocumentStart); err != nil { + t.Fatal(err) + } + call := <-navigator.calls + call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} + first := waitResult(t, worker) + ctx, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + route := func(epoch uint64, focus, document atspi.ObjectID) error { + return worker.RouteDocumentBraille(ctx, epoch, focus, document, webID("/text"), "é猫 word", 0, 4) + } + if err := route(first.DocumentEpoch, first.DocumentFocus, first.DocumentPosition.Document.ID()); err != nil { + t.Fatal(err) + } + routed := waitResult(t, worker) + if !routed.DocumentBrailleRoute || !routed.Moved || routed.DocumentPosition.Offset != 4 || routed.DocumentBrailleLine == nil { + t.Fatalf("route result = %+v", routed) + } + if native := <-navigator.routes; !native.Position.Valid() || native.Offset != 4 || native.Focus != first.DocumentFocus { + t.Fatalf("native route = %+v", native) + } + if err := worker.MoveDocumentCaret(CaretNextCharacter); err != nil { + t.Fatal(err) + } + if result := waitResult(t, worker); result.DocumentPosition.Offset != 5 || result.DocumentCaretText != "o" { + t.Fatalf("arrow did not follow routed cursor: %+v", result) + } + navigator.routeErr = atspi.ErrStaleObject + if err := route(first.DocumentEpoch, first.DocumentFocus, first.DocumentPosition.Document.ID()); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatalf("native refusal = %v", err) + } + if failed := waitResult(t, worker); failed.Moved { + t.Fatal("native refusal moved the virtual cursor") + } + <-navigator.routes + if err := worker.MoveDocumentCaret(CaretNextCharacter); err != nil { + t.Fatal(err) + } + if result := waitResult(t, worker); result.DocumentPosition.Offset != 6 { + t.Fatalf("failed routing changed cursor: %+v", result) + } + navigator.routeErr = nil + canceled, stopCanceled := context.WithCancel(ctx) + stopCanceled() + if err := worker.RouteDocumentBraille(canceled, first.DocumentEpoch, first.DocumentFocus, first.DocumentPosition.Document.ID(), webID("/text"), "é猫 word", 0, 0); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled routing = %v", err) + } + if err := route(first.DocumentEpoch, first.DocumentFocus, webID("/other")); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatalf("other page route = %v", err) + } + waitResult(t, worker) + worker.ResetDocument(first.DocumentFocus) + if err := route(first.DocumentEpoch, first.DocumentFocus, first.DocumentPosition.Document.ID()); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatalf("retired epoch route = %v", err) + } + if len(navigator.routes) != 0 { + t.Fatal("stale route reached native owner") + } +} diff --git a/internal/review/document_braille_test.go b/internal/review/document_braille_test.go new file mode 100644 index 0000000..3d9b0ca --- /dev/null +++ b/internal/review/document_braille_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type documentBrailleReader struct { + *fakeCapturer + line atspi.DocumentCaretText + err error + calls int +} + +func (reader *documentBrailleReader) ReadDocumentCaret(_ context.Context, _ atspi.ObjectReference, _ int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + if unit != atspi.DocumentCaretBrailleLine { + return atspi.DocumentCaretText{}, errors.New("expected raw braille line") + } + reader.calls++ + return reader.line, reader.err +} + +func TestDocumentBrailleReadsOnlyEnabledAcceptedNavigation(t *testing.T) { + reader := &documentBrailleReader{fakeCapturer: &fakeCapturer{}, line: atspi.DocumentCaretText{Start: 10, End: 17, Text: "é猫 line"}} + worker, err := NewWorker(reader, time.Second) + if err != nil { + t.Fatal(err) + } + cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): "Text"}) + position, err := cursor.tree.Reference(webID("/text")) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + result := Result{Moved: true, DocumentCaretCommand: CaretNextCharacter, DocumentFocus: webID("/text"), + DocumentPosition: DocumentPosition{Object: position, Offset: 12}} + for _, test := range []struct { + name string + modify func(*Result) + }{ + {"disabled", func(*Result) { worker.SetDocumentBrailleEnabled(false) }}, + {"boundary", func(r *Result) { r.Moved = false }}, + {"navigation failure", func(r *Result) { r.Err = atspi.ErrStaleObject }}, + {"placement failure", func(r *Result) { r.CaretErr = atspi.ErrStaleObject }}, + {"retired page", func(r *Result) { r.DocumentEpoch++ }}, + {"other focus", func(r *Result) { r.DocumentFocus = webID("/other") }}, + {"table", func(r *Result) { r.DocumentCaretCommand = TableRight }}, + {"password", func(r *Result) { r.DocumentPosition.Snapshot.Role = "password text" }}, + {"explicit link", func(r *Result) { + r.DocumentPosition.Snapshot = atspi.Snapshot{Role: "link", Name: "Example Game 2 copies", ExplicitName: true} + }}, + } { + t.Run(test.name, func(t *testing.T) { + worker.SetDocumentBrailleEnabled(true) + r := result + test.modify(&r) + got := worker.readDocumentBraille(context.Background(), r) + if got.DocumentBrailleLine != nil || reader.calls != 0 { + t.Fatalf("unaccepted navigation read display text: %+v; calls=%d", got, reader.calls) + } + }) + } + worker.SetDocumentBrailleEnabled(true) + got := worker.readDocumentBraille(context.Background(), result) + if got.DocumentBrailleLine == nil || got.DocumentBrailleLine.Text != reader.line.Text || reader.calls != 1 { + t.Fatalf("raw line not preserved: %+v; calls=%d", got, reader.calls) + } + reader.err = atspi.ErrStaleObject + got = worker.readDocumentBraille(context.Background(), result) + if got.DocumentBrailleLine != nil || got.Err != nil || !got.Moved || !errors.Is(got.DocumentBrailleErr, reader.err) { + t.Fatalf("braille read failure changed successful navigation: %+v", got) + } +} diff --git a/internal/review/document_caret.go b/internal/review/document_caret.go new file mode 100644 index 0000000..4fee4ec --- /dev/null +++ b/internal/review/document_caret.go @@ -0,0 +1,110 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// DocumentCaretCommand follows Python Cthulhu's browse caret key families. +type DocumentCaretCommand uint8 + +const ( + CaretNextCharacter DocumentCaretCommand = iota + 1 + CaretPreviousCharacter + CaretNextWord + CaretPreviousWord + CaretNextLine + CaretPreviousLine + CaretLineStart + CaretLineEnd + CaretDocumentStart + CaretDocumentEnd + CaretReadLine + TableLeft + TableRight + TableUp + TableDown + TableFirst + TableLast +) + +func (command DocumentCaretCommand) valid() bool { + return command >= CaretNextCharacter && command <= TableLast +} + +func (command DocumentCaretCommand) String() string { + names := [...]string{"invalid", "next-character", "previous-character", + "next-word", "previous-word", "next-line", "previous-line", "line-start", + "line-end", "document-start", "document-end", "read-line", "table-left", + "table-right", "table-up", "table-down", "table-first", "table-last"} + if !command.valid() { + return names[0] + } + return names[command] +} + +type DocumentCaretReader interface { + ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) +} + +type DocumentCaretMove struct { + Position DocumentPosition + Text string + RawText string + Misspelled bool + // Line retains each object's native range for an assembled visual line. + Line *atspi.DocumentLine + TablePrefix, TableSuffix string + + TableRowHeader, TableColumnHeader string + + Moved bool + // Native text range used for this move, in Unicode character offsets. + Start, End int +} + +// MoveCaret advances the independent browse position using AT-SPI text units. +// It does not depend on Chromium's retained native caret. A stale captured +// document is reported to the worker for one bounded refresh. +func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCaretReader, command DocumentCaretCommand) (DocumentCaretMove, error) { + if !command.valid() || reader == nil { + return DocumentCaretMove{}, ErrInvalidDirection + } + cursor.mu.Lock() + defer cursor.mu.Unlock() + if !cursor.active.Valid() || len(cursor.places) == 0 { + return DocumentCaretMove{}, ErrNoCurrent + } + place := &cursor.places[len(cursor.places)-1] + doc, err := cursor.tree.Document(ctx, place.object) + if err != nil { + return DocumentCaretMove{}, err + } + if doc.Context.TopLevel != place.document { + return DocumentCaretMove{}, atspi.ErrStaleObject + } + if command >= TableLeft { + return cursor.moveTable(ctx, doc, place, command) + } + place.tableCell = atspi.ObjectID{} + indices, current := caret_node_indices(doc.Nodes, place.object) + if current < 0 { + return DocumentCaretMove{}, atspi.ErrStaleObject + } + if len(indices) == 0 { + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil + } + switch command { + case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter: + return moveCaretText(ctx, reader, doc, place, indices, current, + command == CaretPreviousWord || command == CaretPreviousCharacter, + command == CaretNextWord || command == CaretPreviousWord) + } + return move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command) +} diff --git a/internal/review/document_caret_glyph_test.go b/internal/review/document_caret_glyph_test.go new file mode 100644 index 0000000..b828bfe --- /dev/null +++ b/internal/review/document_caret_glyph_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "testing" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func glyph_cursor(t *testing.T, parentRole string, modify func(*atspi.Snapshot)) (*DocumentCursor, caretTextReader) { + t.Helper() + reader := caretTextReader{webID("/before"): "Member", webID("/icon"): "\uf005", webID("/after"): "Join Date"} + node := func(path, role string, children ...string) atspi.TreeNode { + text := reader[webID(path)] + snapshot := atspi.NewSnapshot(path, text, role, "", text) + snapshot.BusName, snapshot.StatesKnown = webID(path).BusName, true + snapshot.CharacterCount = utf8.RuneCountInString(text) + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + root := node("/doc", "document web", "/before", "/rank", "/after") + root.Snapshot.Focusable = true // A focusable document must not protect every icon. + parent := node("/rank", parentRole, "/icon") + if parentRole == "pre" || parentRole == "code" { + parent.Snapshot.HTMLTag = parentRole + } + icon := node("/icon", "static") + if modify != nil { + modify(&icon.Snapshot) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + root, node("/before", "static"), parent, icon, node("/after", "static"), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(context.Background(), webID("/before")); err != nil { + t.Fatal(err) + } + return cursor, reader +} + +func TestDocumentCaretSkipsDecorativeGlyph(t *testing.T) { + ctx := context.Background() + for _, command := range []DocumentCaretCommand{CaretNextLine, CaretNextCharacter, CaretNextWord} { + t.Run(command.String(), func(t *testing.T) { + cursor, reader := glyph_cursor(t, "section", nil) + if command != CaretNextLine { + cursor.places[0].offset = len("Member") - 1 + } + move, err := cursor.MoveCaret(ctx, reader, command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/after") { + t.Fatalf("forward: %+v, %v", move, err) + } + move, err = cursor.MoveCaret(ctx, reader, CaretPreviousLine) + if err != nil || !move.Moved || move.Text != "Member" { + t.Fatalf("backward: %+v, %v", move, err) + } + }) + } + // Focus/routing may leave the cursor on an omitted icon. Both directions + // must still reach surrounding text rather than report a stale position. + for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine} { + cursor, reader := glyph_cursor(t, "section", nil) + if _, err := cursor.SyncFocus(ctx, webID("/icon")); err != nil { + t.Fatal(err) + } + move, err := cursor.MoveCaret(ctx, reader, command) + want := "Join Date" + if command == CaretPreviousLine { + want = "Member" + } + if err != nil || !move.Moved || move.Text != want { + t.Fatalf("from icon %s: %+v, %v", command, move, err) + } + } +} + +func TestDocumentCaretPreservesMeaningfulGlyphs(t *testing.T) { + for _, test := range []struct { + name, parentRole string + modify func(*atspi.Snapshot) + }{ + {"link", "link", nil}, + {"code", "code", nil}, + {"pre", "pre", nil}, + {"focusable", "section", func(s *atspi.Snapshot) { s.Focusable = true }}, + {"clickable", "section", func(s *atspi.Snapshot) { s.SupportsClick = true }}, + {"editable", "section", func(s *atspi.Snapshot) { s.Editable, s.Role = true, "entry" }}, + {"named", "section", func(s *atspi.Snapshot) { s.ExplicitName = true }}, + {"description", "section", func(s *atspi.Snapshot) { s.Description = "Rating" }}, + {"unknown states", "section", func(s *atspi.Snapshot) { s.StatesKnown = false }}, + {"ordinary symbol", "section", func(s *atspi.Snapshot) { s.Name, s.Text = "★", "★" }}, + {"mixed text", "section", func(s *atspi.Snapshot) { s.Name, s.Text, s.CharacterCount = "Star \uf005", "Star \uf005", 6 }}, + {"different text", "section", func(s *atspi.Snapshot) { s.Text = "X" }}, + } { + t.Run(test.name, func(t *testing.T) { + cursor, reader := glyph_cursor(t, test.parentRole, test.modify) + move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/icon") { + t.Fatalf("meaningful glyph omitted: %+v, %v", move, err) + } + }) + } +} + +func TestReadAllSkipsDecorativeGlyph(t *testing.T) { + cursor, reader := glyph_cursor(t, "section", nil) + capturer := &readAllCapturer{tree: cursor.tree, caretTextReader: reader} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/before")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil || len(result.ReadAllLines) != 2 { + t.Fatalf("Read All: %+v", result) + } + for index, want := range []string{"Member", "Join Date"} { + if got := result.ReadAllLines[index].Contents[0].Text; got != want { + t.Fatalf("line %d: %q, want %q", index, got, want) + } + } +} diff --git a/internal/review/document_caret_line.go b/internal/review/document_caret_line.go new file mode 100644 index 0000000..eee3986 --- /dev/null +++ b/internal/review/document_caret_line.go @@ -0,0 +1,168 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// move_caret_line handles line and document boundaries under the cursor lock +// held by MoveCaret. Text-unit movement has its separate traversal helper. +func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, command DocumentCaretCommand) (DocumentCaretMove, error) { + unit := atspi.DocumentCaretLine + if command == CaretReadLine { + unit = atspi.DocumentCaretLineRemainder + } + position := 0 + for position < len(indices) && indices[position] < current { + position++ + } + backwards := command == CaretPreviousLine || command == CaretDocumentEnd + if command == CaretDocumentStart { + position = 0 + } else if command == CaretDocumentEnd { + position = len(indices) - 1 + } + offset := place.offset + if position == len(indices) || indices[position] != current || command == CaretDocumentStart { + offset = 0 + } + if command == CaretDocumentEnd { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + if backwards && command != CaretDocumentEnd && (position == len(indices) || indices[position] != current) { + position-- + if position < 0 { + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil + } + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + initialPosition := position + for attempts := 0; attempts < len(indices)+1; attempts++ { + if position < 0 || position >= len(indices) { + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil + } + node := doc.Nodes[indices[position]] + if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) { + if (command == CaretNextLine || command == CaretPreviousLine) && node.Reference == place.object { + if backwards { + position-- + if position >= 0 { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + } else { + position++ + offset = 0 + } + continue + } + place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation + return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil + } + if offset < 0 { + offset = 0 + } + if command == CaretReadLine && offset >= node.Snapshot.CharacterCount { + position++ + offset = 0 + continue + } + if offset >= node.Snapshot.CharacterCount { + offset = node.Snapshot.CharacterCount - 1 + } + piece, readErr := reader.ReadDocumentCaret(ctx, node.Reference, offset, unit) + if readErr != nil && !errors.Is(readErr, atspi.ErrNoDocumentCaret) { + return DocumentCaretMove{}, readErr + } + if readErr == nil && piece.End > piece.Start && strings.TrimSpace(piece.Text) != "\ufffc" { + nextOffset := offset + sameCurrent := node.Reference == place.object || + place.origin == StructuralNavigation && position == initialPosition && indices[position] >= current + switch command { + case CaretNextLine: + if sameCurrent { + nextOffset = piece.End + if nextOffset < piece.CharacterCount { + piece, readErr = reader.ReadDocumentCaret(ctx, node.Reference, nextOffset, unit) + if readErr != nil { + return DocumentCaretMove{}, readErr + } + } else { + position++ + if position >= len(indices) { + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil + } + offset = 0 + continue + } + } + nextOffset = piece.Start + case CaretPreviousLine: + if sameCurrent { + if piece.Start > 0 { + piece, readErr = reader.ReadDocumentCaret(ctx, node.Reference, piece.Start-1, unit) + if readErr != nil { + return DocumentCaretMove{}, readErr + } + } else { + position-- + if position < 0 { + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil + } + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + continue + } + } + nextOffset = piece.Start + case CaretLineStart: + nextOffset = piece.Start + case CaretLineEnd, CaretDocumentEnd: + nextOffset = max(piece.Start, piece.End-1) + case CaretDocumentStart: + nextOffset = piece.Start + } + if piece.End <= piece.Start || strings.TrimSpace(piece.Text) == "\ufffc" { + // An embedded-object placeholder is a container, not spoken text. + if backwards { + position-- + if position >= 0 { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + } else { + position++ + offset = 0 + } + continue + } + spoken := piece.Text + if command == CaretLineStart || command == CaretLineEnd { + character, charErr := reader.ReadDocumentCaret(ctx, node.Reference, nextOffset, atspi.DocumentCaretCharacter) + if charErr != nil { + return DocumentCaretMove{}, charErr + } + spoken = character.Text + } + place.object = node.Reference + place.offset = nextOffset + place.origin = CaretNavigation + return DocumentCaretMove{Position: documentPosition(doc, *place), Text: spoken, RawText: piece.RawText, Misspelled: piece.Misspelled, Moved: true, Start: piece.Start, End: piece.End}, nil + } + if backwards { + position-- + if position >= 0 { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + } else { + position++ + offset = 0 + } + } + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil +} diff --git a/internal/review/document_caret_nodes.go b/internal/review/document_caret_nodes.go new file mode 100644 index 0000000..22a3a7e --- /dev/null +++ b/internal/review/document_caret_nodes.go @@ -0,0 +1,100 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "strings" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func wholeCaretNode(snapshot atspi.Snapshot) bool { + if snapshot.Role == "link" { + return snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" + } + if snapshot.Role == "image" { + return snapshot.Name != "" || snapshot.Description != "" + } + switch snapshot.Role { + case "button", "check box", "check menu item", "list box", "menu item", "page tab", + "radio menu item", "radio button", "push button", "toggle button": + return true + } + return false +} + +// caret_node_indices preserves provider order and treats whole controls as one +// stop. MoveCaret holds the document cursor lock while using these indices. +func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int) { + // Container text normally embeds its child text. Whole controls are one + // caret stop even when their label is a child or they have no text interface. + // Python's web _treatObjectAsWhole makes the same distinction. + indices := make([]int, 0, len(nodes)) + current := -1 + skipped := make(map[atspi.ObjectID]bool) + protected := make(map[atspi.ObjectID]bool, len(nodes)) + var skipChildren func([]atspi.ObjectID) + children := make(map[atspi.ObjectID][]atspi.ObjectID, len(nodes)) + for _, node := range nodes { + children[node.Reference.ID()] = node.Children + } + skipChildren = func(ids []atspi.ObjectID) { + for _, id := range ids { + if skipped[id] { + continue + } + skipped[id] = true + skipChildren(children[id]) + } + } + for index, node := range nodes { + snapshot := node.Snapshot + // Icon-font text inside links, controls, editable text or code can + // carry meaning. Preserve that entire subtree, including static leaves. + protected[node.Reference.ID()] = protected[node.Parent] || snapshot.Editable || + !atspi.IsWebDocumentRole(snapshot.Role) && (snapshot.Focusable || snapshot.SupportsClick || snapshot.ExplicitName) || + snapshot.Role == "link" || wholeCaretNode(snapshot) || + snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" || + strings.Contains(" "+snapshot.XMLRoles+" ", " code ") + if node.Reference == object { + current = index + } + if node.Hidden || skipped[node.Reference.ID()] { + continue + } + if !protected[node.Reference.ID()] && decorative_caret_glyph(node) { + continue + } + if wholeCaretNode(node.Snapshot) { + indices = append(indices, index) + skipChildren(node.Children) + } else if node.Snapshot.Editable && + (node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") && + (node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) { + indices = append(indices, index) + } else if !node.Snapshot.Editable && len(node.Children) == 0 && node.Snapshot.CharacterCount > 0 { + indices = append(indices, index) + } + } + return indices, current +} + +// Python's web hasValidName/isUselessEmptyElement rejects standalone unnamed +// private-use icons. Keep this narrower: only a known non-interactive static +// leaf with one matching name/text character is omitted from browse reading. +func decorative_caret_glyph(node atspi.DocumentNode) bool { + snapshot := node.Snapshot + if snapshot.Role != "static" || !snapshot.StatesKnown || snapshot.Focusable || + snapshot.Editable || node.ParentEditable || snapshot.SupportsClick || snapshot.ExplicitName || + snapshot.Description != "" || len(node.Children) != 0 || snapshot.CharacterCount != 1 || + (snapshot.Text != "" && snapshot.Text != snapshot.Name) { + return false + } + character, size := utf8.DecodeRuneInString(snapshot.Name) + return size == len(snapshot.Name) && unicode.Is(unicode.Co, character) +} diff --git a/internal/review/document_caret_test.go b/internal/review/document_caret_test.go new file mode 100644 index 0000000..ebc41c8 --- /dev/null +++ b/internal/review/document_caret_test.go @@ -0,0 +1,490 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "fmt" + "testing" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type caretTextReader map[atspi.ObjectID]string + +func (reader caretTextReader) ReadDocumentCaret(_ context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + value := reader[reference.ID()] + runes := []rune(value) + if offset < 0 || offset >= len(runes) { + return atspi.DocumentCaretText{CharacterCount: len(runes)}, nil + } + if unit == atspi.DocumentCaretCharacter { + return atspi.DocumentCaretText{Start: offset, End: offset + 1, Text: string(runes[offset]), CharacterCount: len(runes)}, nil + } + if unit == atspi.DocumentCaretWord { + start := offset + for start > 0 && !unicode.IsSpace(runes[start-1]) { + start-- + } + end := offset + for end < len(runes) && !unicode.IsSpace(runes[end]) { + end++ + } + for end < len(runes) && unicode.IsSpace(runes[end]) { + end++ + } + return atspi.DocumentCaretText{Start: start, End: end, Text: string(runes[start:end]), CharacterCount: len(runes)}, nil + } + start, end := offset, offset + for start > 0 && runes[start-1] != '\n' { + start-- + } + if unit == atspi.DocumentCaretLineRemainder { + start = offset + } + for end < len(runes) && runes[end] != '\n' { + end++ + } + if end < len(runes) { + end++ + } + return atspi.DocumentCaretText{Start: start, End: end, Text: string(runes[start:end]), CharacterCount: len(runes)}, nil +} + +func newCaretTextCursor(t *testing.T, reader caretTextReader) *DocumentCursor { + t.Helper() + nodes := []atspi.TreeNode{} + for _, path := range []string{"/doc", "/text", "/next"} { + snapshot := atspi.NewSnapshot(path, "", "static", "", "") + snapshot.BusName = webID(path).BusName + snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)]) + node := atspi.TreeNode{Snapshot: snapshot} + if path == "/doc" { + node.Snapshot.Role = "document web" + node.Children = []atspi.ObjectID{webID("/text"), webID("/next")} + } + nodes = append(nodes, node) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(context.Background(), webID("/text")); err != nil { + t.Fatal(err) + } + return cursor +} + +func TestDocumentCaretFromTrailingNonTextObject(t *testing.T) { + reader := caretTextReader{webID("/text"): "Last line"} + for _, test := range []struct { + command DocumentCaretCommand + path string + moved bool + }{ + {CaretNextLine, "/next", false}, + {CaretPreviousLine, "/text", true}, + {CaretDocumentStart, "/text", true}, + {CaretDocumentEnd, "/text", true}, + } { + t.Run(fmt.Sprint(test.command), func(t *testing.T) { + cursor := newCaretTextCursor(t, reader) + if _, err := cursor.SyncFocus(context.Background(), webID("/next")); err != nil { + t.Fatal(err) + } + move, err := cursor.MoveCaret(context.Background(), reader, test.command) + if err != nil || move.Moved != test.moved || move.Position.Object.ID() != webID(test.path) { + t.Fatalf("move = %+v, %v", move, err) + } + }) + } +} + +func TestDocumentCaretSkipsUnresolvedEmbeddedCharacters(t *testing.T) { + reader := caretTextReader{webID("/text"): "A\ufffc\ufeffB", webID("/next"): "C"} + cursor := newCaretTextCursor(t, reader) + for _, step := range []struct { + command DocumentCaretCommand + text string + offset int + }{ + {CaretNextCharacter, "B", 3}, + {CaretPreviousCharacter, "A", 0}, + } { + move, err := cursor.MoveCaret(context.Background(), reader, step.command) + if err != nil || !move.Moved || move.Text != step.text || move.Position.Offset != step.offset { + t.Fatalf("command %v: got %+v, %v", step.command, move, err) + } + } +} + +func TestDocumentCaretMovesFromConversationIntoControls(t *testing.T) { + reader := caretTextReader{ + webID("/message"): "Last reply", + webID("/label"): "Voice", + } + node := func(path, name, role string, count int, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, role, "", "") + snapshot.BusName = webID(path).BusName + snapshot.CharacterCount = count + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + entry := node("/composer", "Message", "entry", 0) + entry.Snapshot.Editable = true + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "", "document web", 0, "/message", "/voice", "/composer"), + node("/message", "", "static", 10), + node("/voice", "Voice", "button", 0, "/label"), + node("/label", "", "static", 5), + entry, + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/message")); err != nil { + t.Fatal(err) + } + for _, step := range []struct { + command DocumentCaretCommand + path string + moved bool + }{ + {CaretDocumentEnd, "/composer", true}, + {CaretPreviousLine, "/voice", true}, + {CaretPreviousLine, "/message", true}, + {CaretNextLine, "/voice", true}, + {CaretNextLine, "/composer", true}, + {CaretNextLine, "/composer", false}, + {CaretPreviousCharacter, "/voice", true}, + {CaretNextCharacter, "/composer", true}, + } { + move, err := cursor.MoveCaret(ctx, reader, step.command) + if err != nil || move.Moved != step.moved || move.Position.Object.ID() != webID(step.path) { + t.Fatalf("command %v: got %+v, %v; want %s moved %t", step.command, move, err, step.path, step.moved) + } + } +} + +func TestDocumentCaretReachesNamedImageInsideLink(t *testing.T) { + ctx := context.Background() + reader := caretTextReader{webID("/quote_text"): "Quote"} + node := func(path, name, role string, count int, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, role, "", "") + snapshot.BusName = webID(path).BusName + snapshot.CharacterCount = count + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "", "document web", 0, "/quote", "/decorative", "/thumb", "/next"), + node("/quote", "Quote", "link", 5, "/quote_text"), + node("/quote_text", "", "static", 5), + node("/decorative", "", "image", 0), + node("/thumb", "Thumbs up", "link", 1, "/thumb_image"), + node("/thumb_image", "Thumbs up", "image", 0), + node("/next", "Next post", "heading", 0), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, webID("/quote_text")); err != nil { + t.Fatal(err) + } + move, err := cursor.MoveCaret(ctx, reader, CaretNextLine) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/thumb_image") { + t.Fatalf("after Quote: %+v, %v", move, err) + } +} + +func TestDocumentCaretExplicitLinkPreservesNameInsteadOfBadge(t *testing.T) { + ctx := context.Background() + reader := caretTextReader{webID("/heading"): "Recent Games", webID("/badge"): "2", webID("/ordinary_text"): "Ordinary link"} + node := func(path, name, role string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, role, "", "") + snapshot.BusName = webID(path).BusName + snapshot.CharacterCount = len([]rune(reader[webID(path)])) + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + card := node("/card", "Example Game 2 copies", "link", "/badge") + card.Snapshot.ExplicitName = true + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "", "document web", "/heading", "/card", "/ordinary", "/after"), + node("/heading", "Recent Games", "heading"), card, node("/badge", "", "static"), + node("/ordinary", "Ordinary link", "link", "/ordinary_text"), node("/ordinary_text", "", "static"), + node("/after", "After cards", "button"), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, webID("/heading")); err != nil { + t.Fatal(err) + } + for _, step := range []struct { + command DocumentCaretCommand + path, text string + }{ + {CaretNextLine, "/card", ""}, + {CaretNextLine, "/ordinary_text", "Ordinary link"}, + {CaretNextCharacter, "/ordinary_text", "r"}, + {CaretPreviousLine, "/card", ""}, + {CaretPreviousLine, "/heading", "Recent Games"}, + } { + move, err := cursor.MoveCaret(ctx, reader, step.command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Text != step.text { + t.Fatalf("%v: %+v, %v; want %s %q", step.command, move, err, step.path, step.text) + } + if step.path == "/card" && move.Position.Snapshot.Name != "Example Game 2 copies" { + t.Fatalf("lost card label: %+v", move.Position.Snapshot) + } + } +} + +func TestDocumentCaretKeepsDocumentFocusAfterTopLeafIsRemoved(t *testing.T) { + reader := caretTextReader{webID("/first"): "First", webID("/next"): "Next"} + document := atspi.NewSnapshot("/doc", "", "document web", "", "") + document.BusName = webID("/doc").BusName + first := atspi.NewSnapshot("/first", "", "static", "", "") + first.BusName, first.CharacterCount = document.BusName, 5 + next := atspi.NewSnapshot("/next", "", "static", "", "") + next.BusName, next.CharacterCount = document.BusName, 4 + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + {Snapshot: document, Children: []atspi.ObjectID{webID("/first"), webID("/next")}}, + {Snapshot: first}, {Snapshot: next}, + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil { + t.Fatal(err) + } + start, err := cursor.MoveCaret(ctx, reader, CaretDocumentStart) + if err != nil || start.Position.Object.ID() != webID("/first") { + t.Fatalf("top of document: %+v, %v", start, err) + } + if err := tree.Replace(webID("/doc"), []atspi.TreeNode{ + {Snapshot: document, Children: []atspi.ObjectID{webID("/next")}}, + {Snapshot: next}, + }); err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil { + t.Fatalf("stable document focus lost: %v", err) + } + move, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter) + if err != nil || !move.Moved || move.Text != "N" || move.Position.Object.ID() != webID("/next") { + t.Fatalf("arrow after removed top leaf: %+v, %v", move, err) + } +} + +func TestDocumentCaretPreviousLineSkipsPlaceholderBackwards(t *testing.T) { + reader := caretTextReader{webID("/text"): "A", webID("/next"): "\ufffc\nB"} + cursor := newCaretTextCursor(t, reader) + ctx := context.Background() + end, err := cursor.MoveCaret(ctx, reader, CaretDocumentEnd) + if err != nil || end.Text != "B" { + t.Fatalf("document end: %+v, %v", end, err) + } + previous, err := cursor.MoveCaret(ctx, reader, CaretPreviousLine) + if err != nil || !previous.Moved || previous.Text != "A" { + t.Fatalf("previous line across placeholder: %+v, %v", previous, err) + } +} + +func TestDocumentCaretWordDirectionAndWhitespace(t *testing.T) { + reader := caretTextReader{webID("/text"): "Alpha beta 猫", webID("/next"): "Next"} + cursor := newCaretTextCursor(t, reader) + ctx := context.Background() + // Python nextContext/previousContext starts one character away, skips + // whitespace, and then chooses the containing word, even on reversal. + for _, step := range []struct { + command DocumentCaretCommand + path string + offset int + text string + }{ + {CaretNextWord, "/text", 5, "Alpha "}, + {CaretNextWord, "/text", 11, "beta "}, + {CaretPreviousWord, "/text", 6, "beta "}, + {CaretPreviousWord, "/text", 0, "Alpha "}, + {CaretNextWord, "/text", 5, "Alpha "}, + {CaretNextWord, "/text", 11, "beta "}, + {CaretNextWord, "/text", 13, "猫"}, + {CaretNextWord, "/next", 4, "Next"}, + {CaretPreviousCharacter, "/next", 3, "t"}, + {CaretPreviousWord, "/next", 0, "Next"}, + {CaretPreviousWord, "/text", 12, "猫"}, + } { + move, err := cursor.MoveCaret(ctx, reader, step.command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset || move.Text != step.text { + t.Fatalf("command %v: got %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text) + } + } +} + +type invalidWordReader struct { + caretTextReader + word atspi.DocumentCaretText + err error +} + +func (reader invalidWordReader) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + if reference.ID() == webID("/text") && unit == atspi.DocumentCaretWord { + return reader.word, reader.err + } + return reader.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit) +} + +func TestDocumentCaretWordAcrossInvalidEmojiRange(t *testing.T) { + text := caretTextReader{webID("/text"): "🧻", webID("/next"): "After emoji"} + for _, test := range []struct { + name string + word atspi.DocumentCaretText + }{ + {"negative end", atspi.DocumentCaretText{Start: 0, End: -1, Text: "�", CharacterCount: 1}}, + {"empty range", atspi.DocumentCaretText{CharacterCount: 1}}, + {"range outside text", atspi.DocumentCaretText{Start: 0, End: 2, Text: "�", CharacterCount: 1}}, + {"empty text", atspi.DocumentCaretText{Start: 0, End: 1, CharacterCount: 1}}, + } { + t.Run(test.name, func(t *testing.T) { + reader := invalidWordReader{caretTextReader: text, word: test.word} + cursor := newCaretTextCursor(t, text) + ctx := context.Background() + if _, err := cursor.MoveCaret(ctx, reader, CaretDocumentEnd); err != nil { + t.Fatal(err) + } + for _, step := range []struct { + command DocumentCaretCommand + path string + offset int + text string + }{ + {CaretPreviousWord, "/next", 6, "emoji"}, + {CaretPreviousWord, "/next", 0, "After "}, + {CaretPreviousWord, "/text", 0, "🧻"}, + {CaretNextWord, "/next", 5, "After "}, + {CaretPreviousWord, "/next", 0, "After "}, + {CaretPreviousWord, "/text", 0, "🧻"}, + } { + move, err := cursor.MoveCaret(ctx, reader, step.command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset || move.Text != step.text { + t.Fatalf("%v: got %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text) + } + } + }) + } +} + +func TestDocumentCaretWordPreservesNativeErrors(t *testing.T) { + text := caretTextReader{webID("/text"): "🧻🧻", webID("/next"): "After"} + for _, nativeErr := range []error{atspi.ErrStaleObject, context.Canceled, atspi.ErrNoDocumentCaret} { + reader := invalidWordReader{caretTextReader: text, err: nativeErr} + cursor := newCaretTextCursor(t, text) + move, err := cursor.MoveCaret(context.Background(), reader, CaretNextWord) + if !errors.Is(err, nativeErr) || move.Moved { + t.Fatalf("native error %v: got %+v, %v", nativeErr, move, err) + } + } +} + +func TestDocumentCaretContinuesFromStructuralHeading(t *testing.T) { + reader := caretTextReader{ + webID("/first_text"): "First heading", + webID("/old_text"): "Old position", + webID("/second_text"): "Second heading", + webID("/next_text"): "Text after second heading", + } + node := func(path, role string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, path, role, "", "") + snapshot.BusName = webID(path).BusName + snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)]) + if role == "heading" { + snapshot.HeadingLevel = 2 + } + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + tree, err := atspi.NewAccessibleTree(webID("/root"), []atspi.TreeNode{ + node("/root", "frame", "/doc"), + node("/doc", "document web", "/first", "/old", "/second", "/next"), + node("/first", "heading", "/first_text"), + node("/first_text", "static"), + node("/old", "paragraph", "/old_text"), + node("/old_text", "static"), + node("/second", "heading", "/second_text"), + node("/second_text", "static"), + node("/next", "paragraph", "/next_text"), + node("/next_text", "static"), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/old_text")); err != nil { + t.Fatal(err) + } + jump, err := cursor.Move(ctx, DocumentHeading, 0, false) + if err != nil || jump.Position.Object.ID() != webID("/second") { + t.Fatalf("heading jump = %+v, %v", jump, err) + } + line, err := cursor.MoveCaret(ctx, reader, CaretNextLine) + if err != nil || !line.Moved || line.Text != "Text after second heading" { + t.Fatalf("Down after heading = %+v, %v", line, err) + } + if _, err := cursor.SyncFocus(ctx, webID("/next_text")); err != nil { + t.Fatal(err) + } + character, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter) + if err != nil || character.Text != "e" || character.Position.Offset != 1 { + t.Fatalf("Right after line = %+v, %v", character, err) + } + start, err := cursor.MoveCaret(ctx, reader, CaretDocumentStart) + if err != nil || start.Text != "First heading" || start.Position.Object.ID() != webID("/first_text") { + t.Fatalf("Ctrl+Home = %+v, %v", start, err) + } +} diff --git a/internal/review/document_caret_text.go b/internal/review/document_caret_text.go new file mode 100644 index 0000000..f93ec67 --- /dev/null +++ b/internal/review/document_caret_text.go @@ -0,0 +1,112 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Python caret_navigation starts a word move at the adjacent non-space +// character, rather than the next/previous word boundary. In particular, +// reversing at the end of a word must read that word, not skip over it. +func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, backwards, byWord bool) (DocumentCaretMove, error) { + step := 1 + if backwards { + step = -1 + } + position := 0 + for position < len(indices) && indices[position] < current { + position++ + } + offset := 0 + if position < len(indices) && indices[position] == current { + offset = place.offset + step + } else if backwards { + position-- + if position >= 0 { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + } else if place.origin == StructuralNavigation { + // Structural jumps speak the first descendant at offset zero. + offset = 1 + } + for position >= 0 && position < len(indices) { + if err := ctx.Err(); err != nil { + return DocumentCaretMove{}, err + } + node := doc.Nodes[indices[position]] + if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) { + if node.Reference != place.object { + place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation + return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil + } + position += step + offset = 0 + if backwards && position >= 0 { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + continue + } + if offset >= 0 && offset < node.Snapshot.CharacterCount { + character, err := reader.ReadDocumentCaret(ctx, node.Reference, offset, atspi.DocumentCaretCharacter) + if err != nil && !errors.Is(err, atspi.ErrNoDocumentCaret) { + return DocumentCaretMove{}, err + } + if err == nil && character.End > character.Start { + value, _ := utf8.DecodeRuneInString(character.Text) + if character.Text == "" || byWord && unicode.IsSpace(value) || value == '\ufffc' || value == '\ufeff' { + offset += step + continue + } + if !byWord { + place.object, place.offset, place.origin = node.Reference, character.Start, CaretNavigation + return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Misspelled: character.Misspelled, Moved: true, Start: character.Start, End: character.End}, nil + } + word, err := reader.ReadDocumentCaret(ctx, node.Reference, offset, atspi.DocumentCaretWord) + if err != nil { + return DocumentCaretMove{}, err + } + if word.Text == "" || word.Start < 0 || word.Start > offset || word.End <= offset || word.End > word.CharacterCount { + // Chromium can expose an emoji as a valid character but + // return a broken WORD range (for example, end = -1). + // Read that character as a unit rather than trapping word + // navigation at the same position on every key press. + if character.Start < 0 || character.Start > offset || character.End <= offset || character.End > character.CharacterCount { + return DocumentCaretMove{}, atspi.ErrNoDocumentCaret + } + word = character + } + nextOffset := word.Start + if !backwards { + nextOffset = word.End + // WORD ranges may contain a trailing delimiter. Query its + // actual character; spoken word text can be normalized. + last, err := reader.ReadDocumentCaret(ctx, node.Reference, word.End-1, atspi.DocumentCaretCharacter) + if err != nil { + return DocumentCaretMove{}, err + } + value, _ := utf8.DecodeRuneInString(last.Text) + if unicode.IsSpace(value) { + nextOffset-- + } + } + place.object, place.offset, place.origin = node.Reference, nextOffset, CaretNavigation + return DocumentCaretMove{Position: documentPosition(doc, *place), Text: word.Text, Misspelled: word.Misspelled, Moved: true, Start: word.Start, End: word.End}, nil + } + } + position += step + offset = 0 + if backwards && position >= 0 { + offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1 + } + } + return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil +} diff --git a/internal/review/document_line_worker.go b/internal/review/document_line_worker.go new file mode 100644 index 0000000..d169b8a --- /dev/null +++ b/internal/review/document_line_worker.go @@ -0,0 +1,86 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// ReadDocumentLine queues a caret-line capture and assembly request. +func (worker *Worker) ReadDocumentLine(focus atspi.ObjectID, offset int) (uint64, error) { + if !focus.Valid() { + return 0, ErrInvalidDirection + } + sequence := worker.lineSequence.Add(1) + if sequence == 0 { + sequence = worker.lineSequence.Add(1) + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), line: true, lineRequest: sequence, lineFocus: focus, lineOffset: offset, queuedAt: time.Now()}: + return sequence, nil + default: + return 0, ErrRequestQueueFull + } +} + +func (worker *Worker) readDocumentLine(ctx context.Context, tree *atspi.AccessibleTree, request uint64, focus atspi.ObjectID, offset int) (Result, *atspi.AccessibleTree) { + result := Result{DocumentLine: true, DocumentLineRequest: request, DocumentLineFocus: focus} + if !focus.Valid() { + result.Err = ErrNoCurrent + return result, tree + } + if tree != nil { + if reference, err := tree.Reference(focus); err == nil { + if line, err := tree.DocumentLineAtOffset(ctx, reference, offset); err == nil { + result.DocumentLineData = line + result.Moved = true + return result, tree + } + } + } + captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + captureStarted := time.Now() + tree, err := worker.capturer.CaptureDocumentText(captureContext, focus) + result.CaptureDuration = time.Since(captureStarted) + cancel() + if err != nil { + result.Err = err + return result, tree + } + if tree == nil { + result.Err = errors.New("capture document text tree: nil tree") + return result, nil + } + reference, err := tree.Reference(focus) + if err != nil { + result.Err = err + return result, tree + } + line, err := tree.DocumentLineAtOffset(ctx, reference, offset) + if err != nil { + result.Err = err + return result, tree + } + result.DocumentLineData = line + result.Moved = true + return result, tree +} diff --git a/internal/review/document_mode.go b/internal/review/document_mode.go new file mode 100644 index 0000000..59c70cf --- /dev/null +++ b/internal/review/document_mode.go @@ -0,0 +1,185 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import "git.stormux.org/storm/cthulhu/internal/atspi" + +// DocumentMode is a policy result, not permission to install keyboard grabs. +// Inactive suspends web commands outside a live document, including chrome. +type DocumentMode uint8 + +const ( + InactiveMode DocumentMode = iota + BrowseMode + FocusMode +) + +// ModeSettings mirrors the three Python web navigation-origin preferences. +type ModeSettings struct { + StructuralTriggersFocus bool + CaretTriggersFocus bool + NativeTriggersFocus bool +} + +func DefaultModeSettings() ModeSettings { + return ModeSettings{NativeTriggersFocus: true} +} + +// ModeTraits contains semantic facts which the current owned snapshots do not +// yet capture. A future native resolver must supply these before runtime key +// routing uses this policy; false must not stand in for an unknown query. +type ModeTraits struct { + LayoutOnlyTable bool + TextBlock bool + NamedActionWithoutUsefulChildren bool + PDFViewer bool + GridDescendant bool + ContentEditableWithEmbeddedObjects bool +} + +type ModeInput struct { + Context atspi.DocumentContext + Traits ModeTraits + Origin NavigationOrigin + SayAll bool + UnmodifiedArrow bool + PreviousNavigableTooltip bool +} + +// DocumentModeState belongs to one browser policy owner. It performs no native +// work, speech, or input mutation. Its zero value starts in browse mode. The +// owner serializes calls and resets it when that browser owner is retired. +// Chrome/application suspension does not discard sticky state. +type DocumentModeState struct { + current DocumentMode + sticky DocumentMode +} + +func (state *DocumentModeState) Current() DocumentMode { + if state.current == FocusMode { + return FocusMode + } + return BrowseMode +} + +func (state *DocumentModeState) Sticky() DocumentMode { return state.sticky } + +// Toggle is a manual toggle: like Python it clears either sticky mode. +func (state *DocumentModeState) Toggle() DocumentMode { + if state.Current() == FocusMode { + state.current = BrowseMode + } else { + state.current = FocusMode + } + state.sticky = InactiveMode + return state.current +} + +func (state *DocumentModeState) SetSticky(mode DocumentMode) { + if mode != BrowseMode && mode != FocusMode { + return + } + state.current, state.sticky = mode, mode +} + +// Update follows scripts/web/script.py:useFocusMode precedence. The caller +// resolves a fresh DocumentContext; stale identities must not reuse a cached +// context. Inactive results never change the remembered web mode. +func (state *DocumentModeState) Update(input ModeInput, settings ModeSettings) DocumentMode { + if !input.Context.Document.Valid() || !input.Context.TopLevel.Valid() || len(input.Context.Ancestors) == 0 || input.Context.Ancestors[0].Defunct || !input.Context.Ancestors[0].StatesKnown { + return InactiveMode + } + mode := state.decide(input, settings) + state.current = mode + return mode +} + +func (state *DocumentModeState) decide(input ModeInput, settings ModeSettings) DocumentMode { + if state.sticky != InactiveMode { + return state.sticky + } + if input.SayAll { + return BrowseMode + } + switch input.Origin { + case StructuralNavigation: + if !settings.StructuralTriggersFocus { + return BrowseMode + } + case CaretNavigation: + if !settings.CaretTriggersFocus && !input.PreviousNavigableTooltip { + return BrowseMode + } + default: + if !settings.NativeTriggersFocus { + return state.Current() + } + } + if focusModeWidget(input) { + return FocusMode + } + if state.Current() != FocusMode { + return BrowseMode + } + role := input.Context.Ancestors[0].Role + if input.UnmodifiedArrow && (role == "link" || role == "radio button") { + return FocusMode + } + if hasModeAncestor(input, "embedded") { + if role == "document web" || role == "tool tip" && input.Context.Ancestors[0].Focused { + return BrowseMode + } + return FocusMode + } + return BrowseMode +} + +func focusModeWidget(input ModeInput) bool { + object := input.Context.Ancestors[0] + traits := input.Traits + if object.Editable || object.Expandable && object.Focusable && object.Role != "link" { + return true + } + switch object.Role { + case "combo box", "entry", "list box", "menu", "menu item", "check menu item", "radio menu item", "page tab", "password text", "progress bar", "slider", "spin button", "tool bar", "tree item", "tree table", "tree": + return true + case "table", "table cell": + if traits.LayoutOnlyTable { + return false + } + case "list item": + return hasModeAncestor(input, "list box") + } + if object.HasPopup && (object.Role == "button" || object.Role == "push button" || object.Role == "toggle button") { + return true + } + if (object.Role == "embedded" || object.Role == "table" || object.Role == "table cell") && + !traits.TextBlock && !traits.NamedActionWithoutUsefulChildren && !traits.PDFViewer { + return true + } + return traits.GridDescendant || hasModeAncestor(input, "menu") || hasModeAncestor(input, "tool bar") || traits.ContentEditableWithEmbeddedObjects +} + +func hasModeAncestor(input ModeInput, role string) bool { + for _, ancestor := range input.Context.Ancestors[1:] { + if ancestor.Role == role { + return true + } + } + return false +} diff --git a/internal/review/document_mode_test.go b/internal/review/document_mode_test.go new file mode 100644 index 0000000..2a13536 --- /dev/null +++ b/internal/review/document_mode_test.go @@ -0,0 +1,219 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func modeInput(t *testing.T, role string, ancestors ...string) ModeInput { + t.Helper() + tree, _ := webCursor(t) + ref, err := tree.Reference(webID("/first")) + if err != nil { + t.Fatal(err) + } + context, err := tree.DocumentContext(ref) + if err != nil { + t.Fatal(err) + } + context.Ancestors = []atspi.Snapshot{{Role: role, StatesKnown: true}} + for _, role := range ancestors { + context.Ancestors = append(context.Ancestors, atspi.Snapshot{Role: role}) + } + context.Ancestors = append(context.Ancestors, atspi.Snapshot{Role: "document web"}) + return ModeInput{Context: context} +} + +func TestDocumentModeWidgetRoles(t *testing.T) { + for _, role := range []string{"combo box", "entry", "list box", "menu", "menu item", "check menu item", "radio menu item", "page tab", "password text", "progress bar", "slider", "spin button", "tool bar", "tree item", "tree table", "tree", "table", "table cell", "embedded"} { + t.Run(role, func(t *testing.T) { + var state DocumentModeState + if got := state.Update(modeInput(t, role), DefaultModeSettings()); got != FocusMode { + t.Fatalf("got %v", got) + } + }) + } + for _, role := range []string{"heading", "paragraph", "link", "push button", "check box", "radio button", "list item", "document web"} { + t.Run(role, func(t *testing.T) { + var state DocumentModeState + if got := state.Update(modeInput(t, role), DefaultModeSettings()); got != BrowseMode { + t.Fatalf("got %v", got) + } + }) + } +} + +func TestDocumentModeWidgetSemantics(t *testing.T) { + for _, tc := range []struct { + name string + role string + ancestors []string + traits ModeTraits + editable, expandable bool + focusable, hasPopup bool + want DocumentMode + }{ + {name: "editable paragraph", role: "paragraph", editable: true, want: FocusMode}, + {name: "expandable button", role: "push button", expandable: true, focusable: true, want: FocusMode}, + {name: "unfocusable expandable", role: "push button", expandable: true, want: BrowseMode}, + {name: "expandable link", role: "link", expandable: true, focusable: true, want: BrowseMode}, + {name: "native popup button", role: "button", hasPopup: true, want: FocusMode}, + {name: "popup toggle", role: "toggle button", hasPopup: true, want: FocusMode}, + {name: "popup link", role: "link", hasPopup: true, want: BrowseMode}, + {name: "popup button", role: "push button", hasPopup: true, want: FocusMode}, + {name: "listbox item", role: "list item", ancestors: []string{"list box"}, want: FocusMode}, + {name: "ordinary list", role: "list item", ancestors: []string{"list"}, want: BrowseMode}, + {name: "menu descendant", role: "check box", ancestors: []string{"menu"}, want: FocusMode}, + {name: "toolbar descendant", role: "push button", ancestors: []string{"tool bar"}, want: FocusMode}, + {name: "grid descendant", role: "check box", traits: ModeTraits{GridDescendant: true}, want: FocusMode}, + {name: "layout table", role: "table", traits: ModeTraits{LayoutOnlyTable: true, GridDescendant: true}, want: BrowseMode}, + {name: "layout cell", role: "table cell", traits: ModeTraits{LayoutOnlyTable: true}, want: BrowseMode}, + {name: "editable layout cell", role: "table cell", editable: true, traits: ModeTraits{LayoutOnlyTable: true}, want: FocusMode}, + {name: "PDF table", role: "table", traits: ModeTraits{PDFViewer: true}, want: BrowseMode}, + {name: "text block", role: "embedded", traits: ModeTraits{TextBlock: true}, want: BrowseMode}, + {name: "named action", role: "embedded", traits: ModeTraits{NamedActionWithoutUsefulChildren: true}, want: BrowseMode}, + {name: "embedded editor", role: "section", traits: ModeTraits{ContentEditableWithEmbeddedObjects: true}, want: FocusMode}, + } { + t.Run(tc.name, func(t *testing.T) { + input := modeInput(t, tc.role, tc.ancestors...) + input.Traits = tc.traits + input.Context.Ancestors[0].Editable = tc.editable + input.Context.Ancestors[0].Expandable = tc.expandable + input.Context.Ancestors[0].Focusable = tc.focusable + input.Context.Ancestors[0].HasPopup = tc.hasPopup + var state DocumentModeState + if got := state.Update(input, DefaultModeSettings()); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestDocumentModePrecedence(t *testing.T) { + for _, tc := range []struct { + name string + origin NavigationOrigin + settings ModeSettings + sticky DocumentMode + sayAll, tooltip bool + want DocumentMode + }{ + {name: "native default", settings: DefaultModeSettings(), want: FocusMode}, + {name: "structural default", origin: StructuralNavigation, settings: DefaultModeSettings(), want: BrowseMode}, + {name: "caret default", origin: CaretNavigation, settings: DefaultModeSettings(), want: BrowseMode}, + {name: "structural opt in", origin: StructuralNavigation, settings: ModeSettings{StructuralTriggersFocus: true}, want: FocusMode}, + {name: "caret opt in", origin: CaretNavigation, settings: ModeSettings{CaretTriggersFocus: true}, want: FocusMode}, + {name: "tooltip escape", origin: CaretNavigation, tooltip: true, want: FocusMode}, + {name: "say all", settings: DefaultModeSettings(), sayAll: true, want: BrowseMode}, + {name: "sticky focus beats say all", sticky: FocusMode, sayAll: true, origin: StructuralNavigation, want: FocusMode}, + {name: "sticky browse beats editable", sticky: BrowseMode, settings: DefaultModeSettings(), want: BrowseMode}, + {name: "native disabled retains browse", want: BrowseMode}, + } { + t.Run(tc.name, func(t *testing.T) { + var state DocumentModeState + state.SetSticky(tc.sticky) + input := modeInput(t, "entry") + input.Origin, input.SayAll, input.PreviousNavigableTooltip = tc.origin, tc.sayAll, tc.tooltip + if got := state.Update(input, tc.settings); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } + state := DocumentModeState{} + state.Toggle() + if got := state.Update(modeInput(t, "paragraph"), ModeSettings{}); got != FocusMode { + t.Fatal("disabled native policy did not preserve focus mode") + } +} + +func TestDocumentModeFocusRetention(t *testing.T) { + for _, tc := range []struct { + name, role string + ancestors []string + arrow, focused bool + want DocumentMode + }{ + {name: "arrow link", role: "link", arrow: true, want: FocusMode}, + {name: "tab link", role: "link", want: BrowseMode}, + {name: "arrow radio", role: "radio button", arrow: true, want: FocusMode}, + {name: "arrow button", role: "push button", arrow: true, want: BrowseMode}, + {name: "web app button", role: "push button", ancestors: []string{"embedded"}, want: FocusMode}, + {name: "web app document", role: "document web", ancestors: []string{"embedded"}, want: BrowseMode}, + {name: "focused tooltip", role: "tool tip", ancestors: []string{"embedded"}, focused: true, want: BrowseMode}, + {name: "unfocused tooltip", role: "tool tip", ancestors: []string{"embedded"}, want: FocusMode}, + } { + t.Run(tc.name, func(t *testing.T) { + input := modeInput(t, tc.role, tc.ancestors...) + input.UnmodifiedArrow, input.Context.Ancestors[0].Focused = tc.arrow, tc.focused + var state DocumentModeState + state.Toggle() + if got := state.Update(input, DefaultModeSettings()); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + // Retention conditions must not themselves enter focus mode. + state = DocumentModeState{} + if got := state.Update(input, DefaultModeSettings()); got != BrowseMode { + t.Fatalf("entered focus mode: %v", got) + } + }) + } +} + +func TestDocumentModeStickySuspensionAndToggle(t *testing.T) { + for _, sticky := range []DocumentMode{FocusMode, BrowseMode} { + var state DocumentModeState + state.SetSticky(sticky) + if got := state.Update(ModeInput{}, DefaultModeSettings()); got != InactiveMode { + t.Fatal("web policy active outside document") + } + if state.Sticky() != sticky || state.Current() != sticky { + t.Fatal("suspension lost sticky mode") + } + input := modeInput(t, "entry") + if got := state.Update(input, DefaultModeSettings()); got != sticky { + t.Fatal("return lost sticky mode") + } + input.Context.Ancestors[0].Defunct = true + if got := state.Update(input, DefaultModeSettings()); got != InactiveMode { + t.Fatal("defunct object activated policy") + } + if got := state.Toggle(); got == sticky || state.Sticky() != InactiveMode { + t.Fatal("manual toggle failed to reverse and clear sticky mode") + } + } +} + +func TestDocumentModeUnknownStates(t *testing.T) { + input := modeInput(t, "entry") + input.Context.Ancestors[0].StatesKnown = false + var state DocumentModeState + state.SetSticky(FocusMode) + if got := state.Update(input, DefaultModeSettings()); got != InactiveMode { + t.Fatalf("missing native states enabled mode: %v", got) + } + if state.Current() != FocusMode || state.Sticky() != FocusMode { + t.Fatal("unavailable states discarded remembered mode") + } + input.Context.Ancestors[0].StatesKnown = true + if got := state.Update(input, DefaultModeSettings()); got != FocusMode { + t.Fatalf("recaptured states did not restore mode: %v", got) + } +} diff --git a/internal/review/document_native_caret.go b/internal/review/document_native_caret.go new file mode 100644 index 0000000..aa0c63e --- /dev/null +++ b/internal/review/document_native_caret.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Native selections can report their caret on a label/paragraph rather than +// its static text child. Transfer offsets only when both expose the identical +// text range; embedded or ambiguous child offsets must never be guessed. +func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader DocumentCaretReader, focus atspi.ObjectID, offset int) error { + if offset < 0 { + return atspi.ErrNoDocumentCaret + } + if _, err := cursor.SyncFocus(ctx, focus); err != nil { + return err + } + cursor.mu.Lock() + defer cursor.mu.Unlock() + place := &cursor.places[len(cursor.places)-1] + doc, err := cursor.tree.Document(ctx, place.focus) + if err != nil { + return err + } + node, err := native_caret_node(ctx, reader, doc, focus, offset) + if err != nil { + return err + } + + place.object, place.offset, place.origin = node.Reference, offset, CaretNavigation + place.tableCell = atspi.ObjectID{} + return nil +} + +func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, error) { + nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) + for _, node := range doc.Nodes { + nodes[node.Reference.ID()] = node + } + node := nodes[focus] + if !node.Reference.Valid() || offset < 0 || offset > node.Snapshot.CharacterCount || node.Snapshot.Editable || node.Snapshot.Role == "password text" { + return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret + } + for len(node.Children) != 0 && !wholeCaretNode(node.Snapshot) { + if len(node.Children) != 1 { + return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret + } + child, ok := nodes[node.Children[0]] + if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.CharacterCount != node.Snapshot.CharacterCount { + return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret + } + probeOffset := max(0, min(offset, node.Snapshot.CharacterCount-1)) + parentText, err := reader.ReadDocumentCaret(ctx, node.Reference, probeOffset, atspi.DocumentCaretBrailleLine) + if err != nil { + return atspi.DocumentNode{}, err + } + childText, err := reader.ReadDocumentCaret(ctx, child.Reference, probeOffset, atspi.DocumentCaretBrailleLine) + if err != nil { + return atspi.DocumentNode{}, err + } + if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text { + return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret + } + node = child + } + return node, nil +} + +// Use the navigation FIFO so a subsequent intercepted arrow sees the native +// selection endpoint, even if capturing the document is still in progress. +func (worker *Worker) SyncDocumentCaret(focus atspi.ObjectID, offset int) error { + if !focus.Valid() || offset < 0 { + return atspi.ErrNoDocumentCaret + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), nativeCaret: true, nativeCaretFocus: focus, nativeCaretOffset: offset}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) syncDocumentCaret(ctx context.Context, cursor *DocumentCursor, request request) (Result, *DocumentCursor) { + result := Result{DocumentNativeCaret: true, DocumentEpoch: request.documentEpoch, DocumentFocus: request.nativeCaretFocus} + if worker.focus() != request.nativeCaretFocus { + result.Err = atspi.ErrStaleObject + return result, cursor + } + reader, ok := worker.nativeCapturerFor(request.nativeCaretFocus).(DocumentCaretReader) + if !ok { + result.Err = atspi.ErrNoDocumentCaret + return result, cursor + } + ctx, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + if cursor == nil { + cursor, result.Err = worker.refreshDocumentCursor(ctx, nil, request.nativeCaretFocus) + if result.Err != nil { + return result, cursor + } + } + result.Err = cursor.syncNativeCaret(ctx, reader, request.nativeCaretFocus, request.nativeCaretOffset) + if documentNeedsCapture(result.Err) { + fresh, captureErr := worker.refreshDocumentCursor(ctx, cursor, request.nativeCaretFocus) + if captureErr == nil && worker.focus() == request.nativeCaretFocus { + cursor = fresh + result.Err = cursor.syncNativeCaret(ctx, reader, request.nativeCaretFocus, request.nativeCaretOffset) + } else if captureErr != nil { + result.Err = captureErr + } + } + if result.Err == nil { + result.DocumentPosition, result.Err = cursor.SyncFocus(ctx, request.nativeCaretFocus) + } + return result, cursor +} diff --git a/internal/review/document_native_caret_test.go b/internal/review/document_native_caret_test.go new file mode 100644 index 0000000..66c8314 --- /dev/null +++ b/internal/review/document_native_caret_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 review + +import ( + "context" + "errors" + "testing" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func mirroredCaretCursor(t *testing.T, text caretTextReader, multiple bool) *DocumentCursor { + t.Helper() + var nodes []atspi.TreeNode + for _, path := range []string{"/doc", "/paragraph", "/text", "/other"} { + snapshot := atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: "static", CharacterCount: utf8.RuneCountInString(text[webID(path)])} + node := atspi.TreeNode{Snapshot: snapshot} + if path == "/doc" { + node.Snapshot.Role = "document web" + node.Children = []atspi.ObjectID{webID("/paragraph")} + } + if path == "/paragraph" { + node.Snapshot.Role = "paragraph" + node.Children = []atspi.ObjectID{webID("/text")} + if multiple { + node.Children = append(node.Children, webID("/other")) + } + } + if path == "/other" && !multiple { + continue + } + nodes = append(nodes, node) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + return cursor +} + +func TestNativeCaretSelectionEndpointTransfersToTextChild(t *testing.T) { + text := caretTextReader{webID("/paragraph"): "When autocomplete", webID("/text"): "When autocomplete"} + cursor := mirroredCaretCursor(t, text, false) + for _, offset := range []int{6, 11, 17, 9, 0} { + if err := cursor.syncNativeCaret(context.Background(), text, webID("/paragraph"), offset); err != nil { + t.Fatal(err) + } + move, err := cursor.MoveCaret(context.Background(), text, CaretPreviousCharacter) + if err != nil { + t.Fatal(err) + } + if offset > 0 && (move.Position.Object.ID() != webID("/text") || move.Position.Offset != offset-1) { + t.Fatalf("offset %d: move = %+v", offset, move) + } + } +} + +func TestNativeCaretRejectsAmbiguousOffsetDomains(t *testing.T) { + for _, test := range []struct { + name, child string + multiple bool + offset int + }{ + {"different text same count", "other", false, 2}, + {"different count", "shorter", false, 2}, + {"embedded text", "hello", false, 2}, + {"multiple children", "hello", true, 2}, + {"invalid offset", "hello", false, 6}, + } { + t.Run(test.name, func(t *testing.T) { + parent := "hello" + if test.name == "embedded text" { + parent = "\ufffc" + } + text := caretTextReader{webID("/paragraph"): parent, webID("/text"): test.child} + cursor := mirroredCaretCursor(t, text, test.multiple) + if err := cursor.syncNativeCaret(context.Background(), text, webID("/paragraph"), test.offset); !errors.Is(err, atspi.ErrNoDocumentCaret) { + t.Fatalf("sync = %v", err) + } + }) + } +} + +func TestWorkerNativeCaretHandoffPrecedesNextArrow(t *testing.T) { + text := caretTextReader{webID("/text"): "When autocomplete"} + navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: text} + worker, err := NewWorker(navigator, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + cursor := newCaretTextCursor(t, text) + syncResult, cursor := worker.syncDocumentCaret(context.Background(), cursor, request{nativeCaretFocus: webID("/text"), nativeCaretOffset: 11}) + if syncResult.Err != nil || syncResult.DocumentPosition.Offset != 11 { + t.Fatalf("handoff = %+v", syncResult) + } + result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0) + if result.Err != nil || result.DocumentPosition.Offset != 12 || result.DocumentCaretText != "p" || navigator.offset != 12 { + t.Fatalf("move after selection = %+v; native offset = %d", result, navigator.offset) + } + worker.SyncFocus(webID("/next")) + stale, _ := worker.syncDocumentCaret(context.Background(), cursor, request{nativeCaretFocus: webID("/text"), nativeCaretOffset: 2}) + if !errors.Is(stale.Err, atspi.ErrStaleObject) { + t.Fatalf("stale sync = %+v", stale) + } +} diff --git a/internal/review/document_requests.go b/internal/review/document_requests.go new file mode 100644 index 0000000..60a85a5 --- /dev/null +++ b/internal/review/document_requests.go @@ -0,0 +1,69 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "time" +) + +func (worker *Worker) MoveDocumentCaret(command DocumentCaretCommand) error { + if !command.valid() { + return ErrInvalidDirection + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), caretCommand: command, queuedAt: time.Now()}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) ActivateDocumentCaret() error { + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), activate: true, queuedAt: time.Now()}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) ReadAll(ctx context.Context) error { + if ctx == nil { + return errors.New("read all: nil context") + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), readAll: ctx, queuedAt: time.Now()}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) JumpToDocumentFragment(fragment string) error { + if fragment == "" { + return ErrInvalidDirection + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), fragment: fragment, queuedAt: time.Now()}: + return nil + default: + return ErrRequestQueueFull + } +} diff --git a/internal/review/document_table.go b/internal/review/document_table.go new file mode 100644 index 0000000..4378751 --- /dev/null +++ b/internal/review/document_table.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "fmt" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// moveTable uses provider coordinates, never child order as a rectangular grid. +// The virtual coordinate survives a spanning cell so reversing direction keeps +// the user's column/row. Nested tables have independent coordinate spaces. +func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentSnapshot, place *documentPlace, command DocumentCaretCommand) (DocumentCaretMove, error) { + nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) + parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes)) + for _, node := range doc.Nodes { + nodes[node.Reference.ID()] = node + for _, child := range node.Children { + parents[child] = node.Reference.ID() + } + } + nearestTable := func(id atspi.ObjectID) atspi.ObjectID { + for depth := 0; id.Valid() && depth < 128; depth++ { + if nodes[id].Snapshot.Role == "table" || nodes[id].Snapshot.Role == "tree table" { + return id + } + id = parents[id] + } + return atspi.ObjectID{} + } + table := nearestTable(place.object.ID()) + message := func(text string) (DocumentCaretMove, error) { + return DocumentCaretMove{Position: documentPosition(doc, *place), Text: text}, nil + } + if !table.Valid() { + return message("Not in a table.") + } + var current atspi.DocumentNode + for id, depth := place.object.ID(), 0; id.Valid() && id != table && depth < 128; id, depth = parents[id], depth+1 { + role := nodes[id].Snapshot.Role + if (role == "table cell" || role == "row header" || role == "column header") && !nodes[id].Snapshot.TablePositionKnown { + return message("Table coordinates unavailable.") + } + if nodes[id].Snapshot.TablePositionKnown { + current = nodes[id] + break + } + } + rows, columns := nodes[table].Snapshot.TableRowCount, nodes[table].Snapshot.TableColumnCount + if rows <= 0 || columns <= 0 { + return message("Table coordinates unavailable.") + } + row, col := current.Snapshot.TableRowIndex, current.Snapshot.TableColumnIndex + if current.Reference.Valid() && place.tableCell == current.Reference.ID() { + row, col = place.tableRow, place.tableColumn + } + dr, dc := 0, 0 + switch command { + case TableLeft: + dc = -1 + case TableRight: + dc = 1 + case TableUp: + dr = -1 + case TableDown: + dr = 1 + case TableFirst: + row, col = 0, 0 + case TableLast: + row, col = rows-1, columns-1 + } + // A table structural stop starts at its first cell on directional movement. + if current.Reference.Valid() { + row += dr + col += dc + } else if command != TableLast { + row, col = 0, 0 + } + cells := make([]atspi.DocumentNode, 0) + for _, node := range doc.Nodes { + if node.Snapshot.TablePositionKnown && !node.Hidden && nearestTable(node.Reference.ID()) == table { + cells = append(cells, node) + } + } + for attempts := 0; attempts <= len(cells)+1; attempts++ { + if err := ctx.Err(); err != nil { + return DocumentCaretMove{}, err + } + if col < 0 { + return message("Beginning of row.") + } + if col >= columns { + return message("End of row.") + } + if row < 0 { + return message("Top of column.") + } + if row >= rows { + return message("Bottom of column.") + } + var target atspi.DocumentNode + for _, node := range cells { + s := node.Snapshot + if row >= s.TableRowIndex && row < s.TableRowIndex+max(1, s.TableRowSpan) && col >= s.TableColumnIndex && col < s.TableColumnIndex+max(1, s.TableColumnSpan) { + target = node + break + } + } + if !target.Reference.Valid() { + return message("Table cell unavailable.") + } + s := target.Snapshot + if target.Reference == current.Reference && (dr != 0 || dc != 0) { + // Jump over the span rather than iterating potentially huge dimensions. + if dr > 0 { + row = s.TableRowIndex + max(1, s.TableRowSpan) + } + if dr < 0 { + row = s.TableRowIndex - 1 + } + if dc > 0 { + col = s.TableColumnIndex + max(1, s.TableColumnSpan) + } + if dc < 0 { + col = s.TableColumnIndex - 1 + } + continue + } + parts := []string{} + rowHeader, columnHeader := "", "" + if s.TableRowHeaders != "" && s.TableRowHeaders != current.Snapshot.TableRowHeaders { + rowHeader = s.TableRowHeaders + parts = append(parts, rowHeader) + } + if s.TableColumnHeaders != "" && s.TableColumnHeaders != current.Snapshot.TableColumnHeaders { + columnHeader = s.TableColumnHeaders + parts = append(parts, columnHeader) + } + label := strings.TrimSpace(s.Name) + prefix := strings.Join(parts, " ") + prefixCount := len(parts) + if label == "" { + label = strings.TrimSpace(strings.ReplaceAll(s.Text, "\ufffc", "")) + } + if label == "" { + label = "blank" + } + parts = append(parts, label, fmt.Sprintf("Row %d, column %d.", s.TableRowIndex+1, s.TableColumnIndex+1)) + if s.TableRowSpan > 1 { + parts = append(parts, fmt.Sprintf("Spans %d rows.", s.TableRowSpan)) + } + if s.TableColumnSpan > 1 { + parts = append(parts, fmt.Sprintf("Spans %d columns.", s.TableColumnSpan)) + } + suffix := strings.Join(parts[prefixCount+1:], " ") + place.object, place.offset, place.origin = target.Reference, 0, StructuralNavigation + place.tableCell, place.tableRow, place.tableColumn = target.Reference.ID(), row, col + return DocumentCaretMove{Position: documentPosition(doc, *place), Text: strings.Join(parts, " "), TablePrefix: prefix, TableSuffix: suffix, + TableRowHeader: rowHeader, TableColumnHeader: columnHeader, Moved: true}, nil + } + return message("Table cell unavailable.") +} diff --git a/internal/review/document_table_test.go b/internal/review/document_table_test.go new file mode 100644 index 0000000..f848cc5 --- /dev/null +++ b/internal/review/document_table_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestTableNavigationSpansHeadersAndBoundaries(t *testing.T) { + node := func(path, role, name string, children ...string) atspi.TreeNode { + s := atspi.NewSnapshot(path, name, role, "", "") + s.BusName = webID(path).BusName + n := atspi.TreeNode{Snapshot: s} + for _, child := range children { + n.Children = append(n.Children, webID(child)) + } + return n + } + nodes := []atspi.TreeNode{node("/doc", "document web", "", "/table", "/outside"), node("/table", "table", "", "/a", "/b", "/c", "/d", "/e"), node("/outside", "paragraph", "Outside")} + nodes[1].Snapshot.TableRowCount, nodes[1].Snapshot.TableColumnCount = 3, 2 + for _, c := range []struct { + path, name string + row, col, rs, cs int + }{{"/a", "A", 0, 0, 1, 1}, {"/b", "B", 0, 1, 1, 1}, {"/c", "Merged", 1, 0, 1, 2}, {"/d", "D", 2, 0, 1, 1}, {"/e", "", 2, 1, 1, 1}} { + n := node(c.path, "table cell", c.name) + n.Snapshot.TablePositionKnown = true + n.Snapshot.TableRowIndex, n.Snapshot.TableColumnIndex = c.row, c.col + n.Snapshot.TableRowSpan, n.Snapshot.TableColumnSpan = c.rs, c.cs + if c.col == 1 { + n.Snapshot.TableColumnHeaders = "Second" + } + nodes = append(nodes, n) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, _ := NewDocumentCursor(tree) + cursor.SyncFocus(context.Background(), webID("/table")) + for _, step := range []struct { + command DocumentCaretCommand + path, text string + moved bool + }{ + {TableFirst, "/a", "Row 1, column 1.", true}, + {TableRight, "/b", "Second B", true}, + {TableDown, "/c", "Spans 2 columns.", true}, + {TableDown, "/e", "blank Row 3, column 2.", true}, + {TableUp, "/c", "Merged", true}, + {TableUp, "/b", "B", true}, + {TableUp, "/b", "Top of column.", false}, + {TableRight, "/b", "End of row.", false}, + {TableLast, "/e", "blank", true}, + {TableDown, "/e", "Bottom of column.", false}, + {TableFirst, "/a", "A", true}, + {TableLeft, "/a", "Beginning of row.", false}, + } { + got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.command) + if err != nil || got.Moved != step.moved || got.Position.Object.ID() != webID(step.path) || !strings.Contains(got.Text, step.text) { + t.Fatalf("command %v: %+v, %v", step.command, got, err) + } + } + cursor.SyncFocus(context.Background(), webID("/outside")) + got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, TableRight) + if err != nil || got.Moved || got.Text != "Not in a table." { + t.Fatalf("outside: %+v %v", got, err) + } +} + +func TestTableNavigationRowSpanAndNestedTable(t *testing.T) { + makeNode := func(path, role string, children ...string) atspi.TreeNode { + s := atspi.NewSnapshot(path, path, role, "", "") + s.BusName = webID(path).BusName + n := atspi.TreeNode{Snapshot: s} + for _, child := range children { + n.Children = append(n.Children, webID(child)) + } + return n + } + doc := makeNode("/doc", "document web", "/table") + table := makeNode("/table", "table", "/a", "/b", "/c") + table.Snapshot.TableRowCount, table.Snapshot.TableColumnCount = 2, 2 + a := makeNode("/a", "table cell", "/nested") + b := makeNode("/b", "table cell") + c := makeNode("/c", "table cell") + for _, n := range []*atspi.TreeNode{&a, &b, &c} { + n.Snapshot.TablePositionKnown = true + n.Snapshot.TableRowSpan = 1 + n.Snapshot.TableColumnSpan = 1 + } + a.Snapshot.TableRowSpan = 2 + b.Snapshot.TableColumnIndex = 1 + c.Snapshot.TableRowIndex = 1 + c.Snapshot.TableColumnIndex = 1 + nested := makeNode("/nested", "table", "/inner") + nested.Snapshot.TableRowCount = 1 + nested.Snapshot.TableColumnCount = 1 + inner := makeNode("/inner", "table cell") + inner.Snapshot.TablePositionKnown = true + inner.Snapshot.TableRowSpan = 1 + inner.Snapshot.TableColumnSpan = 1 + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{doc, table, a, b, c, nested, inner}) + if err != nil { + t.Fatal(err) + } + cursor, _ := NewDocumentCursor(tree) + cursor.SyncFocus(context.Background(), webID("/c")) + for _, step := range []struct { + cmd DocumentCaretCommand + path string + }{{TableLeft, "/a"}, {TableRight, "/c"}, {TableUp, "/b"}, {TableLeft, "/a"}, {TableRight, "/b"}} { + got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.cmd) + if err != nil || got.Position.Object.ID() != webID(step.path) { + t.Fatalf("%v: %+v %v", step.cmd, got, err) + } + } + cursor.SyncFocus(context.Background(), webID("/inner")) + got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, TableRight) + if err != nil || got.Moved || got.Text != "End of row." { + t.Fatalf("nested table escaped: %+v %v", got, err) + } +} diff --git a/internal/review/document_test.go b/internal/review/document_test.go new file mode 100644 index 0000000..5e57c97 --- /dev/null +++ b/internal/review/document_test.go @@ -0,0 +1,514 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "fmt" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func webID(path string) atspi.ObjectID { return atspi.NewObjectID(":1.73", path) } + +func TestLinkedHeadingNavigationAndActivation(t *testing.T) { + for _, tc := range []struct { + name, heading, link string + multiple bool + }{ + {"heading contains link", "/heading", "/link", false}, + {"link contains heading", "/heading", "/link", false}, + {"ambiguous links", "/heading", "", true}, + {"leading post link", "/heading", "/link1", false}, + {"leading link without text offsets", "/heading", "/link1", false}, + {"plain text before multiple links", "/heading", "", true}, + {"plain text omitted from children", "/heading", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + node := func(path, role string, children ...string) atspi.TreeNode { + s := atspi.NewSnapshot(path, "", role, "", "") + s.BusName = webID(path).BusName + if role == "heading" { + s.Name, s.HeadingLevel = "Linked heading", 2 + } + if role == "link" { + s.Visited = true + } + n := atspi.TreeNode{Snapshot: s} + for _, child := range children { + n.Children = append(n.Children, webID(child)) + } + return n + } + nodes := []atspi.TreeNode{node("/doc", "document web", "/start"), node("/start", "paragraph")} + switch tc.name { + case "link contains heading": + nodes[0].Children = append(nodes[0].Children, webID("/link")) + nodes = append(nodes, node("/link", "link", "/heading"), node("/heading", "heading")) + case "ambiguous links", "leading post link", "leading link without text offsets", "plain text before multiple links", "plain text omitted from children": + nodes[0].Children = append(nodes[0].Children, webID("/heading")) + nodes = append(nodes, node("/heading", "heading", "/link1", "/link2"), node("/link1", "link"), node("/link2", "link")) + nodes[2].Snapshot.SupportsClick = true + if tc.name != "ambiguous links" { + nodes[3].Children = []atspi.ObjectID{webID("/post_text")} + nodes[4].Children = []atspi.ObjectID{webID("/date_text")} + nodes = append(nodes, node("/post_text", "static"), node("/date_text", "static")) + nodes[5].Snapshot.Name, nodes[5].Snapshot.CharacterCount = "#1", 2 + nodes[6].Snapshot.Name, nodes[6].Snapshot.CharacterCount = "Date", 4 + } + if tc.name == "leading link without text offsets" || tc.name == "plain text omitted from children" { + nodes[2].Snapshot.Name = "#1 Date" + nodes[3].Snapshot.Name, nodes[4].Snapshot.Name = "#1", "Date" + nodes[5].Snapshot.CharacterCount, nodes[6].Snapshot.CharacterCount = 0, 0 + } + if tc.name == "plain text omitted from children" { + nodes[2].Snapshot.Name = "Post #1 Date" + } + if tc.name == "plain text before multiple links" { + nodes[2].Children = append([]atspi.ObjectID{webID("/prefix")}, nodes[2].Children...) + nodes = append(nodes, node("/prefix", "static")) + nodes[len(nodes)-1].Snapshot.Name, nodes[len(nodes)-1].Snapshot.CharacterCount = "Post", 4 + } + default: + nodes[0].Children = append(nodes[0].Children, webID("/heading")) + nodes = append(nodes, node("/heading", "heading", "/static"), node("/static", "static", "/link"), node("/link", "link")) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, _ := NewDocumentCursor(tree) + if _, err := cursor.SyncFocus(context.Background(), webID("/start")); err != nil { + t.Fatal(err) + } + move, err := cursor.Move(context.Background(), DocumentHeading, 2, false) + if err != nil || !move.Moved || move.Position.Object.ID() != webID(tc.heading) { + t.Fatalf("heading move = %+v, %v", move, err) + } + target, err := cursor.ActivationTarget() + if tc.multiple { + if move.Position.LinkedHeading != nil || !errors.Is(err, ErrNoCurrent) { + t.Fatalf("ambiguous heading resolved: %+v, %v", move.Position.LinkedHeading, err) + } + } else if err != nil || target.ID() != webID(tc.link) || move.Position.LinkedHeading == nil || !move.Position.LinkedHeading.Visited { + t.Fatalf("linked heading = %+v, target %v, err %v", move.Position, target.ID(), err) + } + }) + } +} + +func TestDocumentFragmentSelectsPostPermalink(t *testing.T) { + node := func(path, name, role, id string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, role, "", "") + snapshot.BusName = webID(path).BusName + snapshot.ElementID = id + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "Forum", "document web", "", "/first", "/second"), + node("/first", "", "section", "p1", "/first_link"), + node("/first_link", "#1", "link", ""), + node("/second", "", "section", "p2", "/heading"), + node("/heading", "#2 User", "heading", "", "/second_link"), + node("/second_link", "#2", "link", ""), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil { + t.Fatal(err) + } + position, found, err := cursor.JumpToFragment(ctx, "p2") + if err != nil || !found || position.Object.ID() != webID("/second_link") { + t.Fatalf("fragment position = %+v, found %t, err %v", position, found, err) + } + if _, found, err := cursor.JumpToFragment(ctx, "absent"); err != nil || found { + t.Fatalf("missing fragment: found %t, err %v", found, err) + } +} + +func webNodes() []atspi.TreeNode { + node := func(path, role string, children ...string) atspi.TreeNode { + s := atspi.NewSnapshot(path, path, role, "", "") + s.BusName = webID(path).BusName + if role == "heading" { + s.HeadingLevel = 2 + } + n := atspi.TreeNode{Snapshot: s} + for _, child := range children { + n.Children = append(n.Children, webID(child)) + } + return n + } + return []atspi.TreeNode{ + node("/root", "frame", "/chrome", "/doc", "/other"), + node("/chrome", "entry"), + node("/doc", "document web", "/first", "/hidden", "/second", "/nested", "/button", "/checkbox", "/list", "/separator"), + node("/first", "heading"), + node("/hidden", "heading"), + node("/second", "heading"), + node("/nested", "document web", "/link"), + func() atspi.TreeNode { + link := node("/link", "link") + link.Snapshot.Focusable = true + return link + }(), + node("/other", "document web", "/otherHeading"), + node("/otherHeading", "heading"), + node("/button", "push button"), + node("/checkbox", "check box"), + node("/list", "list"), + node("/separator", "separator"), + } +} + +func webCursor(t *testing.T) (*atspi.AccessibleTree, *DocumentCursor) { + t.Helper() + nodes := webNodes() + nodes[4].Geometry = atspi.Geometry{VisibilityKnown: true} + nodes[5].Geometry = atspi.Geometry{VisibilityKnown: true, Visible: true} + tree, err := atspi.NewAccessibleTree(webID("/root"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + return tree, cursor +} + +func TestDocumentCursorNavigationAndChromeRoundTrip(t *testing.T) { + _, cursor := webCursor(t) + ctx := context.Background() + position, err := cursor.SyncFocus(ctx, webID("/first")) + if err != nil || position.Origin != NativeNavigation { + t.Fatalf("focus: %+v %v", position, err) + } + move, err := cursor.Move(ctx, DocumentHeading, 2, false) + if err != nil || !move.Moved || move.Position.Snapshot.Path != "/second" || move.Position.Origin != StructuralNavigation || move.Wrapped { + t.Fatalf("heading: %+v %v", move, err) + } + // Duplicate focus events and leaving/returning through chrome preserve + // a separately navigated position; a new native target overrides it. + for _, chrome := range []bool{false, true} { + if chrome { + if _, err := cursor.SyncFocus(ctx, webID("/chrome")); !errors.Is(err, atspi.ErrNotInDocument) { + t.Fatal(err) + } + if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, ErrNoCurrent) { + t.Fatal(err) + } + } + position, err = cursor.SyncFocus(ctx, webID("/first")) + if err != nil || position.Snapshot.Path != "/second" { + t.Fatalf("lost position: %+v %v", position, err) + } + } + move, err = cursor.Move(ctx, DocumentLink, 0, false) + if err != nil || !move.Moved || move.Position.Snapshot.Path != "/link" || move.Position.Document.ID() != webID("/doc") || move.Wrapped { + t.Fatalf("embedded link: %+v %v", move, err) + } + move, err = cursor.Move(ctx, DocumentHeading, 0, false) + if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/first" { + t.Fatalf("forward heading wrap: %+v %v", move, err) + } + move, err = cursor.Move(ctx, DocumentHeading, 0, true) + if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/second" { + t.Fatalf("backward heading: %+v %v", move, err) + } + position, err = cursor.SyncFocus(ctx, webID("/link")) + if err != nil || position.Snapshot.Path != "/link" || position.Origin != NativeNavigation { + t.Fatalf("native focus: %+v %v", position, err) + } +} + +func TestDocumentCursorDoesNotReanchorMatchingNestedTarget(t *testing.T) { + nodes := webNodes() + for i := range nodes { + if nodes[i].Snapshot.Path == "/list" { + nodes[i].Children = []atspi.ObjectID{webID("/inner_list")} + } + } + nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{ + BusName: webID("/inner_list").BusName, Path: "/inner_list", Role: "list", + }}) + tree, err := atspi.NewAccessibleTree(webID("/root"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/inner_list")); err != nil { + t.Fatal(err) + } + move, err := cursor.Move(ctx, DocumentList, 0, false) + if err != nil || move.Position.Snapshot.Path != "/list" || !move.Wrapped { + t.Fatalf("next list should advance from the nested list and wrap: %+v, %v", move, err) + } + if _, err := cursor.SyncFocus(ctx, webID("/separator")); err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, webID("/inner_list")); err != nil { + t.Fatal(err) + } + move, err = cursor.Move(ctx, DocumentList, 0, true) + if err != nil || move.Position.Snapshot.Path != "/list" || move.Wrapped { + t.Fatalf("previous list should reach containing list without wrapping: %+v, %v", move, err) + } +} + +func TestDocumentCursorAnchorsNavigationAtMatchingAncestor(t *testing.T) { + node := func(path, role string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, path, role, "", "") + snapshot.BusName = webID(path).BusName + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + first := node("/first", "link", "/first/text") + first.Snapshot.Focusable = true + second := node("/second", "link") + second.Snapshot.Focusable = true + tree, err := atspi.NewAccessibleTree(webID("/root"), []atspi.TreeNode{ + node("/root", "frame", "/doc"), + node("/doc", "document web", "/first", "/second"), + first, + node("/first/text", "static"), + second, + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(context.Background(), webID("/first/text")); err != nil { + t.Fatal(err) + } + move, err := cursor.Move(context.Background(), DocumentLink, 0, true) + if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/second" { + t.Fatalf("previous link from descendant = %+v, %v", move, err) + } +} + +func TestDocumentCursorStructuralTargets(t *testing.T) { + _, cursor := webCursor(t) + ctx := context.Background() + cursor.SyncFocus(ctx, webID("/first")) + for _, tc := range []struct { + target DocumentTarget + path string + }{ + {DocumentButton, "/button"}, + {DocumentCheckBox, "/checkbox"}, + {DocumentList, "/list"}, + {DocumentSeparator, "/separator"}, + } { + move, err := cursor.Move(ctx, tc.target, 0, false) + if err != nil || !move.Moved || move.Position.Snapshot.Path != tc.path || move.Position.Origin != StructuralNavigation || move.Wrapped { + t.Fatalf("target %v: %+v err=%v", tc.target, move, err) + } + move, err = cursor.Move(ctx, tc.target, 0, false) + if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != tc.path { + t.Fatalf("target %v wrap: %+v err=%v", tc.target, move, err) + } + cursor.SyncFocus(ctx, webID("/first")) + } + if _, err := cursor.Move(ctx, DocumentHeading, 7, false); err == nil { + t.Fatal("invalid heading level accepted") + } +} + +func TestDocumentCursorReferenceStructuralTargets(t *testing.T) { + snapshot := func(path, role string, configure func(*atspi.Snapshot)) atspi.TreeNode { + s := atspi.NewSnapshot(path, path, role, "", "") + s.BusName = webID(path).BusName + if configure != nil { + configure(&s) + } + return atspi.TreeNode{Snapshot: s} + } + nodes := []atspi.TreeNode{ + snapshot("/doc", "document web", nil), + snapshot("/start", "section", nil), + snapshot("/blockquote", "block quote", nil), + snapshot("/combo", "combo box", nil), + snapshot("/entry", "entry", func(s *atspi.Snapshot) { s.Editable, s.Focusable = true, true }), + snapshot("/form", "push button", func(s *atspi.Snapshot) { s.Focusable = true }), + snapshot("/image", "image", nil), + snapshot("/landmark", "landmark", nil), + snapshot("/listitem", "list item", nil), + snapshot("/live", "section", func(s *atspi.Snapshot) { s.LiveRegion = true }), + snapshot("/paragraph", "paragraph", func(s *atspi.Snapshot) { s.CharacterCount = 4 }), + snapshot("/radio", "radio button", nil), + snapshot("/table", "table", func(s *atspi.Snapshot) { s.TableRowCount = 1 }), + snapshot("/tablecell", "table cell", nil), + snapshot("/unvisited", "link", func(s *atspi.Snapshot) { s.Focusable = true }), + snapshot("/visited", "link", func(s *atspi.Snapshot) { s.Focusable, s.Visited = true, true }), + snapshot("/clickable", "section", func(s *atspi.Snapshot) { s.SupportsClick = true }), + snapshot("/chunk", "paragraph", func(s *atspi.Snapshot) { s.CharacterCount, s.EmbeddedObjectCount = 80, 1 }), + snapshot("/heading", "heading", func(s *atspi.Snapshot) { s.HeadingLevel = 3 }), + } + nodes[0].Children = []atspi.ObjectID{ + webID("/start"), webID("/blockquote"), webID("/combo"), webID("/entry"), + webID("/form"), webID("/image"), webID("/landmark"), webID("/listitem"), + webID("/live"), webID("/paragraph"), webID("/radio"), webID("/table"), + webID("/unvisited"), webID("/visited"), webID("/clickable"), + webID("/chunk"), webID("/heading"), + } + nodes[12].Children = []atspi.ObjectID{webID("/tablecell")} + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/start")); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + target DocumentTarget + level int + path string + }{ + {DocumentBlockQuote, 0, "/blockquote"}, + {DocumentComboBox, 0, "/combo"}, + {DocumentEntry, 0, "/entry"}, + {DocumentFormField, 0, "/form"}, + {DocumentImage, 0, "/image"}, + {DocumentLandmark, 0, "/landmark"}, + {DocumentListItem, 0, "/listitem"}, + {DocumentLiveRegion, 0, "/live"}, + {DocumentParagraph, 0, "/paragraph"}, + {DocumentRadioButton, 0, "/radio"}, + {DocumentTable, 0, "/table"}, + {DocumentUnvisitedLink, 0, "/unvisited"}, + {DocumentVisitedLink, 0, "/visited"}, + {DocumentClickable, 0, "/clickable"}, + {DocumentChunk, 0, "/chunk"}, + {DocumentHeading, 3, "/heading"}, + } { + move, err := cursor.Move(ctx, tc.target, tc.level, false) + if err != nil || !move.Moved || move.Position.Snapshot.Path != tc.path { + t.Fatalf("target %v level %d: %+v err=%v", tc.target, tc.level, move, err) + } + if _, err := cursor.SyncFocus(ctx, webID("/start")); err != nil { + t.Fatal(err) + } + } +} + +func TestDocumentCursorTabsAndInvalidation(t *testing.T) { + tree, cursor := webCursor(t) + ctx := context.Background() + cursor.SyncFocus(ctx, webID("/first")) + cursor.Move(ctx, DocumentHeading, 0, false) + cursor.SyncFocus(ctx, webID("/otherHeading")) + position, err := cursor.SyncFocus(ctx, webID("/first")) + if err != nil || position.Snapshot.Path != "/second" { + t.Fatalf("tab restoration: %+v %v", position, err) + } + tree.Invalidate(webID("/doc")) + if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatal(err) + } + if err := tree.Replace(webID("/root"), webNodes()); err != nil { + t.Fatal(err) + } + position, err = cursor.SyncFocus(ctx, webID("/first")) + if err != nil || position.Snapshot.Path != "/first" || position.Origin != NativeNavigation { + t.Fatalf("inherited stale position: %+v %v", position, err) + } + if _, err := cursor.SyncFocus(ctx, webID("/missing")); !errors.Is(err, atspi.ErrObjectNotFound) { + t.Fatal(err) + } + if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, ErrNoCurrent) { + t.Fatal(err) + } +} + +func TestDocumentCursorRejectsReparentedObject(t *testing.T) { + tree, cursor := webCursor(t) + ctx := context.Background() + cursor.SyncFocus(ctx, webID("/first")) + nodes := webNodes() + nodes[2].Children = nodes[2].Children[1:] + nodes[8].Children = append(nodes[8].Children, webID("/first")) + if err := tree.Replace(webID("/root"), nodes); err != nil { + t.Fatal(err) + } + if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatal(err) + } +} + +func TestDocumentCursorCanceledMovePreservesPosition(t *testing.T) { + _, cursor := webCursor(t) + cursor.SyncFocus(context.Background(), webID("/first")) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + move, err := cursor.Move(context.Background(), DocumentHeading, 0, true) + if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/second" { + t.Fatalf("position after canceled move: %+v %v", move, err) + } +} + +func TestDocumentCursorBoundsRememberedDocuments(t *testing.T) { + _, cursor := webCursor(t) + ctx := context.Background() + for i := 0; i < 20; i++ { + nodes := webNodes() + // Simulate reused native paths after each page replacement. + cursor.tree.Invalidate(webID("/doc")) + nodes[3].Snapshot.Name = fmt.Sprintf("page %d", i) + if err := cursor.tree.Replace(webID("/root"), nodes); err != nil { + t.Fatal(err) + } + position, err := cursor.SyncFocus(ctx, webID("/first")) + if err != nil || position.Snapshot.Name != fmt.Sprintf("page %d", i) { + t.Fatalf("page %d position: %+v %v", i, position, err) + } + } + if len(cursor.places) != 16 { + t.Fatalf("remembered %d documents", len(cursor.places)) + } +} diff --git a/internal/review/document_visual_line.go b/internal/review/document_visual_line.go new file mode 100644 index 0000000..545db49 --- /dev/null +++ b/internal/review/document_visual_line.go @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "math" + "strings" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// A visual line is temporary move-owned data. The retained cursor always uses +// an actual object's native offset, never an offset in the joined string. +type visualLinePart struct { + index int + text atspi.DocumentCaretText +} + +type visualLineReader struct { + DocumentCaretReader + cache map[visualLineKey]atspi.DocumentCaretText +} + +type visualLineKey struct { + object atspi.ObjectReference + offset int + unit atspi.DocumentCaretUnit +} + +func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + if err := ctx.Err(); err != nil { + return atspi.DocumentCaretText{}, err + } + key := visualLineKey{object, offset, unit} + if text, ok := reader.cache[key]; ok { + return text, nil + } + text, err := reader.DocumentCaretReader.ReadDocumentCaret(ctx, object, offset, unit) + if err == nil { + reader.cache[key] = text + } + return text, err +} + +func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, command DocumentCaretCommand) (DocumentCaretMove, error) { + reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)} + owners := visual_line_owners(doc.Nodes) + next := *place + // Move beyond the entire current line, even when character/word movement + // left the cursor in a middle fragment or structural navigation chose its + // heading container. The object-local mover still handles empty controls. + if command == CaretNextLine || command == CaretPreviousLine { + position := -1 + for i, index := range indices { + if index == current || place.origin == StructuralNavigation && index > current { + position = i + break + } + } + if position >= 0 { + parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, place.offset, true) + if err != nil { + return DocumentCaretMove{}, err + } + if len(parts) > 1 { + part := parts[0] + if command == CaretNextLine { + part = parts[len(parts)-1] + } + next.object, next.offset, next.origin = doc.Nodes[part.index].Reference, part.text.Start, CaretNavigation + current = part.index + } + } + } + movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, current, command) + if err != nil || !movement.Moved { + if err == nil { + movement.Position = documentPosition(doc, *place) + } + return movement, err + } + position := -1 + for i, index := range indices { + if doc.Nodes[index].Reference == next.object { + position = i + break + } + } + if position >= 0 { + parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, next.offset, command != CaretReadLine) + if err != nil { + return DocumentCaretMove{}, err + } + if len(parts) > 1 { + part := parts[0] + if command == CaretLineEnd || command == CaretDocumentEnd { + part = parts[len(parts)-1] + } + next.object, next.offset = doc.Nodes[part.index].Reference, part.text.Start + if command == CaretLineEnd || command == CaretDocumentEnd { + next.offset = part.text.End - 1 + } + movement.Start, movement.End = part.text.Start, part.text.End + line, raw, misspelled := assemble_visual_line(tree, doc, parts) + movement.Line, movement.RawText, movement.Misspelled = &line, raw, movement.Misspelled || misspelled + movement.Text = strings.Join(strings.Fields(movement.RawText), " ") + if command == CaretLineStart || command == CaretLineEnd { + character, err := reader.ReadDocumentCaret(ctx, next.object, next.offset, atspi.DocumentCaretCharacter) + if err != nil { + return DocumentCaretMove{}, err + } + movement.Text = character.Text + } + movement.Position = documentPosition(doc, next) + } + } + *place = next + return movement, nil +} + +// Retain semantic boundaries even if unrelated blocks have matching y values. +// Inline spans/emphasis do not introduce boundaries. Links remain independent +// stops with their existing activation, voice, state and sound presentation. +func visual_line_owners(nodes []atspi.DocumentNode) map[int]atspi.ObjectID { + owners := make(map[int]atspi.ObjectID, len(nodes)) + byID := make(map[atspi.ObjectID]atspi.ObjectID, len(nodes)) + for index, node := range nodes { + owner := byID[node.Parent] + snapshot := node.Snapshot + switch snapshot.Role { + case "paragraph", "heading", "list item", "table cell", "row header", "column header", "link", "document web", "document frame", "embedded": + owner = node.Reference.ID() + case "section": + if snapshot.HTMLTag != "span" { + owner = node.Reference.ID() + } + } + if snapshot.Editable || wholeCaretNode(snapshot) || snapshot.Role == "password text" || + snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" || strings.Contains(" "+snapshot.XMLRoles+" ", " code ") { + // Exclude the whole subtree, including static code descendants. + owner = atspi.ObjectID{} + byID[node.Reference.ID()] = owner + owners[index] = owner + continue + } + // An excluded ancestor must stay excluded through nested blocks. + if parent, known := byID[node.Parent]; known && !parent.Valid() { + owner = atspi.ObjectID{} + } + byID[node.Reference.ID()] = owner + owners[index] = owner + } + return owners +} + +func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, indices []int, owners map[int]atspi.ObjectID, position, offset int, left bool) ([]visualLinePart, error) { + index := indices[position] + node := doc.Nodes[index] + if !owners[index].Valid() || node.Snapshot.Editable || wholeCaretNode(node.Snapshot) || node.Snapshot.CharacterCount <= 0 { + return nil, nil + } + unit := atspi.DocumentCaretLine + if !left { + unit = atspi.DocumentCaretLineRemainder + } + text, err := reader.ReadDocumentCaret(ctx, node.Reference, min(offset, node.Snapshot.CharacterCount-1), unit) + if errors.Is(err, atspi.ErrNoDocumentCaret) { + return nil, nil + } + if err != nil { + return nil, err + } + if !visual_line_text(text) { + return nil, nil + } + if !left && text.RawText != "" { + // Native remainder speech keeps the full raw line for indentation; + // assembly needs only the characters after the starting caret. + runes := []rune(text.RawText) + if length := text.End - text.Start; length < len(runes) { + text.RawText = string(runes[len(runes)-length:]) + } + } + parts := []visualLinePart{{index, text}} + characters := utf8.RuneCountInString(text.Text) + for _, direction := range []int{-1, 1} { + if direction < 0 && !left { + continue + } + for next := position + direction; next >= 0 && next < len(indices); next += direction { + candidate := indices[next] + if owners[candidate] != owners[index] { + break + } + probe := 0 + if direction < 0 { + probe = doc.Nodes[candidate].Snapshot.CharacterCount - 1 + } + piece, err := reader.ReadDocumentCaret(ctx, doc.Nodes[candidate].Reference, probe, atspi.DocumentCaretLine) + if errors.Is(err, atspi.ErrNoDocumentCaret) { + break + } + if err != nil { + return nil, err + } + if !visual_line_text(piece) || !visual_line_bounds(text.Bounds, piece.Bounds) { + break + } + // A fragment containing a hard newline ends the line, even when + // a provider reports identical/overlapping bounds for both rows. + previous, following := parts[len(parts)-1].text, piece + if direction < 0 { + previous, following = piece, parts[0].text + } + if strings.ContainsAny(previous.RawText, "\r\n") || strings.ContainsAny(previous.Text, "\r\n") || strings.HasPrefix(following.RawText, "\n") || strings.HasPrefix(following.Text, "\n") { + break + } + characters += utf8.RuneCountInString(piece.Text) + if len(parts) >= 256 || characters > 65536 { + return nil, errors.New("document visual line exceeds capture limit") + } + part := visualLinePart{candidate, piece} + if direction < 0 { + parts = append([]visualLinePart{part}, parts...) + } else { + parts = append(parts, part) + } + } + } + return parts, nil +} + +func visual_line_text(text atspi.DocumentCaretText) bool { + return text.Bounds.Valid() && text.Start >= 0 && text.End > text.Start && text.End <= text.CharacterCount && + !strings.ContainsRune(text.Text, '\ufffc') && (text.Text != "" || text.RawText != "") +} + +func visual_line_bounds(first, second atspi.Rectangle) bool { + if !first.Valid() || !second.Valid() { + return false + } + top := max(int64(first.Y), int64(second.Y)) + bottom := min(int64(first.Y)+int64(first.Height), int64(second.Y)+int64(second.Height)) + return bottom > top && math.Abs(float64(first.Y)+float64(first.Height)/2-float64(second.Y)-float64(second.Height)/2) <= 5 +} + +func visual_line_space(first, second atspi.DocumentCaretText) bool { + left, right := first.RawText, second.RawText + if left == "" { + left = first.Text + } + if right == "" { + right = second.Text + } + last, _ := utf8.DecodeLastRuneInString(left) + start, _ := utf8.DecodeRuneInString(right) + gap := max(int64(second.Bounds.X)-int64(first.Bounds.X)-int64(first.Bounds.Width), int64(first.Bounds.X)-int64(second.Bounds.X)-int64(second.Bounds.Width)) + return gap > 1 && !unicode.IsSpace(last) && !unicode.IsSpace(start) +} + +func assemble_visual_line(tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, parts []visualLinePart) (atspi.DocumentLine, string, bool) { + misspelled := false + line := atspi.DocumentLine{Document: doc.Context.TopLevel} + var raw strings.Builder + for i, part := range parts { + value := part.text.RawText + if value == "" { + value = part.text.Text + } + // Preserve native spacing and punctuation across span boundaries. + separator := "" + if i > 0 && visual_line_space(parts[i-1].text, part.text) { + separator = " " + } + raw.WriteString(separator) + raw.WriteString(value) + snapshot := doc.Nodes[part.index].Snapshot + if context, err := tree.DocumentContext(doc.Nodes[part.index].Reference); err == nil { + snapshot.BrailleContext = context.Ancestors + } + snapshot.Misspelled = part.text.Misspelled + misspelled = misspelled || part.text.Misspelled + line.Contents = append(line.Contents, atspi.DocumentContent{Reference: doc.Nodes[part.index].Reference, Snapshot: snapshot, + Start: part.text.Start, End: part.text.End, Text: part.text.Text, RawText: value, Separator: separator, Bounds: part.text.Bounds}) + } + return line, raw.String(), misspelled +} diff --git a/internal/review/document_visual_line_native_test.go b/internal/review/document_visual_line_native_test.go new file mode 100644 index 0000000..9820c15 --- /dev/null +++ b/internal/review/document_visual_line_native_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestNativeBrowserFragmentLines(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + // Reach the textarea without enabling the browser's native caret mode. + if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "Tab").CombinedOutput(); err != nil { + t.Fatalf("focus: %v %s", err, output) + } + var focus atspi.ObjectID + for !focus.Valid() { + select { + case event := <-adapter.Events(): + if (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Fragment probe focus" { + focus = event.Source.ID() + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + tree, err := adapter.CaptureDocument(ctx, focus) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(ctx, focus); err != nil { + t.Fatal(err) + } + reference, err := tree.Reference(focus) + if err != nil { + t.Fatal(err) + } + document, err := tree.Document(ctx, reference) + if err != nil { + t.Fatal(err) + } + glyphCount := 0 + for _, node := range document.Nodes { + if node.Snapshot.Role == "static" && node.Snapshot.Name == "\uf005" && node.Snapshot.CharacterCount == 1 { + glyphCount++ + } + } + if glyphCount < 5 { + t.Fatalf("fixture did not expose its star text objects: %d", glyphCount) + } + for _, step := range []struct { + command DocumentCaretCommand + want string + }{ + {CaretDocumentStart, "Fragmented response"}, + {CaretNextLine, "One two three."}, + {CaretNextLine, "Four five six."}, + {CaretPreviousLine, "One two three."}, + {CaretLineEnd, "."}, + {CaretNextLine, "Four five six."}, + {CaretNextLine, "Next paragraph."}, + {CaretNextLine, "Before"}, + {CaretNextLine, "ordinary link"}, + {CaretNextLine, "after."}, + {CaretNextLine, "Split heading"}, + {CaretNextLine, "Code first"}, + {CaretNextLine, ""}, // Preserve the provider's separate preformatted newline. + {CaretNextLine, "Code second"}, + {CaretNextLine, "Member rank"}, + {CaretNextLine, "Join Date"}, // Skip the five separate decorative stars. + {CaretPreviousLine, "Member rank"}, + {CaretNextLine, "Join Date"}, + } { + move, err := cursor.MoveCaret(ctx, adapter, step.command) + if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.want { + t.Fatalf("%s: %+v, %v; want %q", step.command, move, err, step.want) + } + } + control, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) + if err != nil || !control.Moved || control.Position.Snapshot.Role != "push button" && control.Position.Snapshot.Role != "button" || control.Position.Snapshot.Name != "Rating details" { + t.Fatalf("labeled icon control: %+v, %v", control, err) + } + for _, want := range []string{"Mixed \uf005 text.", "★"} { + move, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) + if err != nil || !move.Moved || strings.TrimSpace(move.Text) != want { + t.Fatalf("meaningful symbol: %+v, %v; want %q", move, err, want) + } + } + if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil { + t.Fatal(err) + } + inline, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) + if err != nil { + t.Fatal(err) + } + worker, err := NewWorker(adapter, 3*time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(focus) + worker.SetDocumentBrailleEnabled(true) + if inline.Line == nil || len(inline.Line.Contents) < 2 { + t.Fatalf("missing inline fragments: %+v", inline) + } + content := inline.Line.Contents[1] + displayRoute := &documentBrailleRequest{ctx: ctx, focus: focus, document: inline.Position.Document.ID(), target: content.Snapshot.ID(), + line: content.RawText, start: content.Start, offset: content.Start + 1, done: make(chan error, 1)} + routed := worker.route_document_braille(ctx, cursor, request{brailleRoute: displayRoute}) + if routed.Err != nil || !routed.Moved || routed.DocumentPosition.Offset != content.Start+1 || routed.DocumentCaretLine == nil || len(routed.DocumentCaretLine.Contents) < 2 { + t.Fatalf("native inline braille route = %+v", routed) + } + if err := <-displayRoute.done; err != nil { + t.Fatal(err) + } + worker.readDocumentBraille(ctx, routed) + // A mismatched displayed source must be rejected by the native owner. + ref, _ := tree.Reference(content.Snapshot.ID()) + if err := adapter.RouteBraille(ctx, atspi.BrailleRoute{Position: ref, Focus: focus, Line: "changed", Start: content.Start, Offset: content.Start}); err == nil { + t.Fatal("native routing accepted changed display text") + } + if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil { + t.Fatal(err) + } + if _, err := cursor.MoveCaret(ctx, adapter, CaretNextLine); err != nil { + t.Fatal(err) + } + result := worker.readAll(ctx, cursor) + if result.Err != nil || len(result.ReadAllLines) < 3 { + t.Fatalf("Read All: %+v", result) + } + for index, want := range []string{"One two three.", "Four five six.", "Next paragraph."} { + if got := result.ReadAllLines[index].Contents[0].Text; got != want { + t.Fatalf("Read All line %d: %q, want %q", index, got, want) + } + } + rankFound := false + for index, line := range result.ReadAllLines { + if line.Contents[0].Text == "Member rank" { + rankFound = true + if index+1 >= len(result.ReadAllLines) || result.ReadAllLines[index+1].Contents[0].Text != "Join Date" { + t.Fatal("Read All stopped on decorative rank icons") + } + } + } + if !rankFound { + t.Fatal("Read All did not reach member rank") + } + // Fragmented headings must also hand Down to following content as a unit. + if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil { + t.Fatal(err) + } + move, err := cursor.Move(ctx, DocumentHeading, 2, false) + if err != nil || !move.Moved { + t.Fatalf("heading: %+v, %v", move, err) + } + next, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) + if err != nil || next.Text != "Code first" { + t.Fatalf("Down after split heading: %+v, %v", next, err) + } +} diff --git a/internal/review/document_visual_line_test.go b/internal/review/document_visual_line_test.go new file mode 100644 index 0000000..f078a2d --- /dev/null +++ b/internal/review/document_visual_line_test.go @@ -0,0 +1,259 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type fragmentTextReader struct { + caretTextReader + bounds map[atspi.ObjectID]atspi.Rectangle + err error +} + +func (reader fragmentTextReader) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + if reader.err != nil { + return atspi.DocumentCaretText{}, reader.err + } + text, err := reader.caretTextReader.ReadDocumentCaret(ctx, ref, offset, unit) + text.RawText, text.Bounds = text.Text, reader.bounds[ref.ID()] + return text, err +} + +func fragment_cursor(t *testing.T, boundary string) (*DocumentCursor, fragmentTextReader) { + t.Helper() + reader := fragmentTextReader{caretTextReader: caretTextReader{ + webID("/one"): "One ", webID("/two"): "two.", webID("/three"): "Three ", webID("/four"): "four.", webID("/after"): "Next paragraph", + }, bounds: make(map[atspi.ObjectID]atspi.Rectangle)} + for _, row := range []struct { + path string + x, y int32 + }{{"/one", 0, 10}, {"/two", 40, 10}, {"/three", 0, 30}, {"/four", 50, 30}, {"/after", 100, 30}} { + reader.bounds[webID(row.path)] = atspi.Rectangle{X: row.x, Y: row.y, Width: 30, Height: 18} + } + node := func(path, role string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, "", role, "", "") + snapshot.BusName, snapshot.CharacterCount = webID(path).BusName, utf8.RuneCountInString(reader.caretTextReader[webID(path)]) + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + block := node("/paragraph", boundary, "/one", "/span", "/three", "/four") + span := node("/span", "section", "/two") + span.Snapshot.HTMLTag = "span" + if boundary == "pre" || boundary == "code" { + block.Snapshot.HTMLTag = boundary + } + if boundary == "entry" { + block.Snapshot.Editable = true + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "document web", "/paragraph", "/next"), block, + node("/one", "static"), span, node("/two", "static"), + node("/three", "static"), node("/four", "static"), node("/next", "paragraph", "/after"), node("/after", "static"), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(context.Background(), webID("/one")); err != nil { + t.Fatal(err) + } + return cursor, reader +} + +func TestDocumentFragmentLines(t *testing.T) { + cursor, reader := fragment_cursor(t, "paragraph") + ctx := context.Background() + for _, step := range []struct { + command DocumentCaretCommand + path, text string + offset int + }{ + {CaretDocumentStart, "/one", "One two.", 0}, + {CaretNextCharacter, "/one", "n", 1}, + {CaretNextLine, "/three", "Three four.", 0}, + {CaretPreviousLine, "/one", "One two.", 0}, + {CaretLineEnd, "/two", ".", 3}, + {CaretLineStart, "/one", "O", 0}, + {CaretNextLine, "/three", "Three four.", 0}, + {CaretNextLine, "/after", "Next paragraph", 0}, + {CaretPreviousLine, "/three", "Three four.", 0}, + } { + move, err := cursor.MoveCaret(ctx, reader, step.command) + if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset { + t.Fatalf("%s: %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text) + } + if move.End > 0 && move.End > move.Position.Snapshot.CharacterCount { + t.Fatal("joined offset escaped native object") + } + } +} + +func TestDocumentFragmentSpacingAndPunctuation(t *testing.T) { + for _, test := range []struct { + first, second, want string + x int32 + }{ + {"One ", "two.", "One two.", 40}, + {"One", "two.", "One two.", 40}, + {"Hello", ",", "Hello,", 30}, + } { + cursor, reader := fragment_cursor(t, "paragraph") + reader.caretTextReader[webID("/one")] = test.first + reader.caretTextReader[webID("/two")] = test.second + bounds := reader.bounds[webID("/two")] + bounds.X = test.x + reader.bounds[webID("/two")] = bounds + // Range lengths come from the provider, just as in a native capture. + doc, _ := cursor.tree.Document(context.Background(), cursor.places[0].object) + var nodes []atspi.TreeNode + for _, node := range doc.Nodes { + if text, ok := reader.caretTextReader[node.Reference.ID()]; ok { + node.Snapshot.CharacterCount = utf8.RuneCountInString(text) + } + nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children}) + } + if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { + t.Fatal(err) + } + move, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart) + if err != nil || move.Text != test.want { + t.Fatalf("fragments %q/%q: %+v, %v; want %q", test.first, test.second, move, err, test.want) + } + } +} + +type fragmentReadAllCapturer struct { + *readAllCapturer + fragmentTextReader +} + +func (capturer *fragmentReadAllCapturer) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + return capturer.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit) +} + +func TestReadAllFragmentLinesAndRemainder(t *testing.T) { + cursor, reader := fragment_cursor(t, "paragraph") + ctx := context.Background() + if _, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter); err != nil { + t.Fatal(err) + } + capturer := &fragmentReadAllCapturer{readAllCapturer: &readAllCapturer{tree: cursor.tree}, fragmentTextReader: reader} + worker, _ := NewWorker(capturer, time.Second) + worker.SyncFocus(webID("/one")) + result := worker.readAll(ctx, cursor) + if result.Err != nil || len(result.ReadAllLines) != 3 { + t.Fatalf("read all: %+v", result) + } + for index, want := range []string{"ne two.", "Three four.", "Next paragraph"} { + if got := result.ReadAllLines[index].Contents[0].Text; got != want { + t.Fatalf("line %d: %q, want %q", index, got, want) + } + } + if cursor.places[0].offset != 1 { + t.Fatal("assembly moved the user's cursor") + } + result.ReadAllProgress(1) + request := <-worker.requests + followed, ok := worker.followReadAll(ctx, cursor, request) + if !ok { + t.Fatal("progress lost ownership") + } + move, err := followed.MoveCaret(ctx, reader, CaretNextLine) + if err != nil || move.Text != "Next paragraph" { + t.Fatalf("Down after progress: %+v, %v", move, err) + } +} + +func TestDocumentFragmentBoundaries(t *testing.T) { + for _, boundary := range []string{"heading", "link", "table cell", "pre", "code", "entry"} { + t.Run(boundary, func(t *testing.T) { + cursor, reader := fragment_cursor(t, boundary) + move, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart) + want := "One two." + if boundary == "pre" || boundary == "code" || boundary == "entry" { + want = "One " + } + if boundary == "entry" { + want = "" + } + if err != nil || move.Text != want { + t.Fatalf("%s: %+v, %v; want %q", boundary, move, err, want) + } + }) + } +} + +func TestDocumentFragmentErrorsDoNotMoveCursor(t *testing.T) { + for _, failure := range []error{context.Canceled, atspi.ErrStaleObject} { + cursor, reader := fragment_cursor(t, "paragraph") + reader.err = failure + before := cursor.places[0] + _, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine) + if !errors.Is(err, failure) || cursor.places[0] != before { + t.Fatalf("error %v: %v; changed cursor", failure, err) + } + } +} + +type wrappedFragmentReader struct{ fragmentTextReader } + +func (reader wrappedFragmentReader) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + text, err := reader.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit) + if ref.ID() == webID("/one") && offset >= 6 { + text.Bounds.Y = 30 + } + return text, err +} + +func TestDocumentFragmentLineAcrossWrappedObject(t *testing.T) { + cursor, base := fragment_cursor(t, "paragraph") + base.caretTextReader[webID("/one")] = "First\nSecond " + base.bounds[webID("/two")] = atspi.Rectangle{X: 60, Y: 30, Width: 30, Height: 18} + base.bounds[webID("/three")] = atspi.Rectangle{X: 0, Y: 50, Width: 30, Height: 18} + base.bounds[webID("/four")] = atspi.Rectangle{X: 50, Y: 50, Width: 30, Height: 18} + ref, _ := cursor.tree.Reference(webID("/one")) + doc, err := cursor.tree.Document(context.Background(), ref) + if err != nil { + t.Fatal(err) + } + nodes := make([]atspi.TreeNode, 0, len(doc.Nodes)) + for _, node := range doc.Nodes { + if node.Reference == ref { + node.Snapshot.CharacterCount = utf8.RuneCountInString(base.caretTextReader[webID("/one")]) + } + nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children}) + } + if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { + t.Fatal(err) + } + reader := wrappedFragmentReader{base} + for _, step := range []struct { + command DocumentCaretCommand + want string + }{ + {CaretDocumentStart, "First"}, {CaretNextLine, "Second two."}, + {CaretPreviousLine, "First"}, {CaretNextLine, "Second two."}, {CaretNextLine, "Three four."}, + } { + move, err := cursor.MoveCaret(context.Background(), reader, step.command) + if err != nil || strings.TrimSpace(move.Text) != step.want { + t.Fatalf("%s: %+v, %v; want %q", step.command, move, err, step.want) + } + } +} diff --git a/internal/review/document_worker.go b/internal/review/document_worker.go new file mode 100644 index 0000000..b5dc852 --- /dev/null +++ b/internal/review/document_worker.go @@ -0,0 +1,492 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// MoveDocument shares the bounded review FIFO, but owns an independent cursor. +func (worker *Worker) MoveDocument(target DocumentTarget, level int, backwards bool) error { + if !validDocumentTarget(target) || level < 0 || level > 6 || target != DocumentHeading && level != 0 { + return ErrInvalidDirection + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch uint64) (Result, *DocumentCursor) { + result := Result{DocumentEpoch: epoch, DocumentTarget: target, DocumentBackwards: backwards, DocumentLevel: level, DocumentFocus: worker.focus()} + if !result.DocumentFocus.Valid() { + result.Err = ErrNoCurrent + return result, cursor + } + move := func() error { + if cursor == nil { + return ErrNoCurrent + } + if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil { + return err + } + move, err := cursor.Move(ctx, target, level, backwards) + result.DocumentPosition = move.Position + result.Moved = move.Moved + result.DocumentWrapped = move.Wrapped + return err + } + result.Err = move() + if !documentNeedsCapture(result.Err) { + return worker.placeDocumentWithRetry(ctx, cursor, result), cursor + } + // A bounded refresh plus one retry handles a second replacement without + // allowing a constantly mutating page to loop indefinitely. + result.CaptureReason = result.Err + captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + captureStarted := time.Now() + tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus) + result.CaptureDuration = time.Since(captureStarted) + cancel() + if err != nil { + result.Err = err + return result, cursor + } + if tree == nil { + result.Err = errors.New("capture document tree: nil tree") + return result, cursor + } + recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result) + if cursor == nil || cursor.tree != tree { + cursor, _ = NewDocumentCursor(tree) + } + if !recovered { + result.DocumentFocus = worker.focus() + } + result.Err = move() + if !result.Moved && documentNeedsCapture(result.Err) && + worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus { + // A second replacement can race the first capture. Retry once while + // the navigation has not moved, so the heading is not skipped. + retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout) + retryStarted := time.Now() + tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus) + result.CaptureDuration += time.Since(retryStarted) + retryCancel() + if err == nil && tree != nil && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus { + if cursor == nil || cursor.tree != tree { + cursor, _ = NewDocumentCursor(tree) + } + result.Err = move() + } + } + return worker.placeDocumentWithRetry(ctx, cursor, result), cursor +} + +func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch uint64) (Result, *DocumentCursor) { + result := Result{DocumentEpoch: epoch, DocumentFragment: true, DocumentFocus: worker.focus()} + if !result.DocumentFocus.Valid() { + result.Err = ErrNoCurrent + return result, cursor + } + captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + captureStarted := time.Now() + tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus) + result.CaptureDuration = time.Since(captureStarted) + cancel() + if err != nil { + result.Err = err + return result, cursor + } + if tree == nil { + result.Err = errors.New("capture document tree: nil tree") + return result, cursor + } + cursor, err = NewDocumentCursor(tree) + if err != nil { + result.Err = err + return result, cursor + } + if _, err = cursor.SyncFocus(ctx, result.DocumentFocus); err != nil { + result.Err = err + return result, cursor + } + result.DocumentPosition, result.Moved, result.Err = cursor.JumpToFragment(ctx, fragment) + if result.Err == nil && result.Moved && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus { + result = worker.placeDocumentWithRetry(ctx, cursor, result) + } + return result, cursor +} + +func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result { + result = worker.placeDocument(ctx, cursor, result) + if !documentCaretNeedsRecapture(result.CaretErr) || !worker.CurrentDocumentResult(result) { + return result + } + // A semantic event can retire the captured tree between selecting a target + // and placing its native caret. Retry only the same target after one fresh + // capture; repeating the navigation command could skip to another target. + oldPosition := result.DocumentPosition + captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus) + cancel() + if err != nil || tree == nil || !worker.CurrentDocumentResult(result) { + return result + } + target, err := tree.Reference(oldPosition.Object.ID()) + if err != nil { + return result + } + owner, err := tree.DocumentContext(target) + if err != nil || owner.TopLevel.ID() != oldPosition.Document.ID() { + return result + } + snapshot, err := tree.Resolve(target) + if err != nil || snapshot.Role != oldPosition.Snapshot.Role || snapshot.HeadingLevel != oldPosition.Snapshot.HeadingLevel { + return result + } + result.DocumentPosition.Object = target + result.DocumentPosition.Document = owner.TopLevel + result.DocumentPosition.Snapshot = snapshot + result.CaretErr = nil + result = worker.placeDocument(ctx, cursor, result) + if result.Err == nil && result.CaretErr == nil && cursor != nil { + cursor.rebindPlace(tree, oldPosition, result.DocumentPosition) + } + return result +} + +func documentCaretNeedsRecapture(err error) bool { + return errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) || + errors.Is(err, atspi.ErrStaleSnapshot) +} + +func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch uint64) (Result, *DocumentCursor) { + result := Result{DocumentEpoch: epoch, DocumentCaretCommand: command, DocumentFocus: worker.focus()} + if !result.DocumentFocus.Valid() { + result.Err = ErrNoCurrent + return result, cursor + } + reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(DocumentCaretReader) + if !ok { + result.Err = errors.New("document caret reader unavailable") + return result, cursor + } + move := func() error { + if cursor == nil { + return ErrNoCurrent + } + if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil { + return err + } + // A word move can cross whitespace and several objects. Bound the + // whole navigation, not just each individual native text request. + navigationContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + movement, err := cursor.MoveCaret(navigationContext, reader, command) + result.DocumentPosition = movement.Position + result.DocumentCaretText = movement.Text + result.DocumentCaretRawText = movement.RawText + result.DocumentCaretLine = movement.Line + result.DocumentTablePrefix, result.DocumentTableSuffix = movement.TablePrefix, movement.TableSuffix + result.DocumentTableRowHeader, result.DocumentTableColumnHeader = movement.TableRowHeader, movement.TableColumnHeader + result.DocumentPosition.Snapshot.Misspelled = movement.Misspelled + result.DocumentCaretStart, result.DocumentCaretEnd = movement.Start, movement.End + result.Moved = movement.Moved + if err == nil && movement.Moved { + context, contextErr := cursor.tree.DocumentContext(movement.Position.Object) + if contextErr != nil { + // ChatGPT can replace a text object between the successful + // caret read and this optional ancestor lookup. The captured + // position and text remain usable; retrying the command here + // would advance past the line the user requested. + if !errors.Is(contextErr, atspi.ErrStaleObject) && !errors.Is(contextErr, atspi.ErrObjectNotFound) && !errors.Is(contextErr, atspi.ErrStaleSnapshot) { + return contextErr + } + return nil + } + result.DocumentCaretContext = context.Ancestors + } + return err + } + result.Err = move() + if !documentNeedsCapture(result.Err) { + return worker.placeDocumentCaret(ctx, result), cursor + } + result.CaptureReason = result.Err + captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + captureStarted := time.Now() + tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus) + result.CaptureDuration = time.Since(captureStarted) + cancel() + if err != nil { + result.Err = err + return result, cursor + } + if tree == nil { + result.Err = errors.New("capture document tree: nil tree") + return result, cursor + } + recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result) + if cursor == nil || cursor.tree != tree { + cursor, _ = NewDocumentCursor(tree) + } + if !recovered { + result.DocumentFocus = worker.focus() + } + result.Err = move() + if result.Moved || !documentNeedsCapture(result.Err) || + !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus { + return worker.placeDocumentCaret(ctx, result), cursor + } + // A dynamic page can replace the captured target between recapture and + // SyncFocus. Retry once only if the first attempt did not move the caret. + retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout) + retryStarted := time.Now() + tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus) + result.CaptureDuration += time.Since(retryStarted) + retryCancel() + if err != nil || tree == nil || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus { + return result, cursor + } + if cursor == nil || cursor.tree != tree { + cursor, _ = NewDocumentCursor(tree) + } + result.Err = move() + return worker.placeDocumentCaret(ctx, result), cursor +} + +func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Result { + if result.Err != nil || !result.Moved || + !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus { + return result + } + if result.DocumentCaretCommand == CaretReadLine { + return result + } + navigator, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface { + SetDocumentCaretPosition(context.Context, atspi.ObjectReference, atspi.ObjectID, int) error + }) + if !ok { + return result + } + deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, result.DocumentPosition.Offset) + return result +} + +func (worker *Worker) activateDocumentCaret(ctx context.Context, cursor *DocumentCursor, epoch uint64) (Result, *DocumentCursor) { + result := Result{DocumentEpoch: epoch, DocumentActivate: true, DocumentFocus: worker.focus()} + if !result.DocumentFocus.Valid() { + result.Err = ErrNoCurrent + return result, cursor + } + target := atspi.ObjectReference{} + resolve := func() error { + if cursor == nil { + return ErrNoCurrent + } + if _, err := cursor.syncActionFocus(ctx, result.DocumentFocus); err != nil { + return err + } + var err error + target, err = cursor.ActivationTarget() + return err + } + result.Err = resolve() + if documentNeedsCapture(result.Err) { + cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus) + if result.Err == nil { + result.Err = resolve() + } + } + if result.Err != nil { + return result, cursor + } + activator, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface { + ActivateDocumentObject(context.Context, atspi.ObjectReference, atspi.ObjectID) error + }) + if !ok { + result.Err = errors.New("document activation unavailable") + return result, cursor + } + actionContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + if !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus { + result.Err = atspi.ErrStaleObject + return result, cursor + } + result.Err = activator.ActivateDocumentObject(actionContext, target, result.DocumentFocus) + if errors.Is(result.Err, atspi.ErrStaleObject) && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus { + // ErrStaleObject is a failed preflight, before any native action ran. + // Refresh once, and retry only the identical target, never a new link. + originalTarget := target.ID() + cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus) + if result.Err == nil { + result.Err = resolve() + } + if result.Err == nil && (target.ID() != originalTarget || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus) { + result.Err = atspi.ErrStaleObject + } + if result.Err == nil { + retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout) + defer retryCancel() + result.Err = activator.ActivateDocumentObject(retryContext, target, result.DocumentFocus) + } + } + return result, cursor +} + +// Navigation can recover by returning to native focus; an action cannot. Check +// the saved target first, before SyncFocus has a chance to replace a stale place. +func (cursor *DocumentCursor) syncActionFocus(ctx context.Context, focus atspi.ObjectID) (DocumentPosition, error) { + if len(cursor.places) > 0 { + saved := cursor.places[len(cursor.places)-1] + if saved.focus.ID() == focus || saved.object.ID() == focus { + if _, err := cursor.tree.Resolve(saved.object); err != nil { + return DocumentPosition{}, err + } + } + } + return cursor.SyncFocus(ctx, focus) +} + +// Refresh before an action, retaining the virtual position only within the same +// document and focus. Never substitute another link if the selected one vanished. +func (worker *Worker) refreshDocumentCursor(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID) (*DocumentCursor, error) { + var saved documentPlace + if cursor != nil && len(cursor.places) > 0 { + saved = cursor.places[len(cursor.places)-1] + } + captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + tree, err := worker.capturer.CaptureDocument(captureContext, focus) + if err != nil { + return cursor, err + } + fresh, err := NewDocumentCursor(tree) + if err != nil { + return cursor, err + } + position, err := fresh.SyncFocus(ctx, focus) + if err != nil { + return cursor, err + } + if saved.document.Valid() && (saved.focus.ID() == focus || saved.object.ID() == focus) { + if saved.document.ID() != position.Document.ID() { + return cursor, atspi.ErrStaleObject + } + target, err := tree.Reference(saved.object.ID()) + if err != nil { + return cursor, err + } + owner, err := tree.DocumentContext(target) + if err != nil || owner.TopLevel != position.Document { + return cursor, atspi.ErrStaleObject + } + place := &fresh.places[len(fresh.places)-1] + place.object, place.offset, place.origin = target, saved.offset, saved.origin + } + return fresh, nil +} + +func (worker *Worker) recoverCapturedDocumentFocus(cursor *DocumentCursor, tree *atspi.AccessibleTree, result *Result) bool { + if cursor == nil || len(cursor.places) == 0 { + return false + } + // The capture reason describes the old tree. Even a text or geometry + // refresh can discover a focus object removed while capture was pending. + // The fresh tree and unchanged document/focus ownership decide recovery. + if _, err := tree.Reference(result.DocumentFocus); !errors.Is(err, atspi.ErrObjectNotFound) { + return false + } + root, err := tree.Root() + if err != nil || root.ID() != cursor.places[len(cursor.places)-1].document.ID() || root.ID().BusName != result.DocumentFocus.BusName { + return false + } + if !worker.recoverDocumentFocus(result.DocumentFocus, root.ID(), result.DocumentEpoch) { + return false + } + result.DocumentFocus = root.ID() + return true +} + +func documentNeedsCapture(err error) bool { + return errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) || + errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrIncompleteTree) || + errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) || errors.Is(err, atspi.ErrStaleSnapshot) +} + +func (worker *Worker) placeDocument(ctx context.Context, cursor *DocumentCursor, result Result) Result { + if cursor != nil && result.Err == nil && result.Moved { + if owner, err := cursor.tree.DocumentContext(result.DocumentPosition.Object); err == nil { + result.DocumentCaretContext = owner.Ancestors + } + } + if !worker.CurrentDocumentResult(result) { + result.Err = atspi.ErrStaleObject + return result + } + navigator, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface { + SetDocumentPosition(context.Context, atspi.ObjectReference, atspi.ObjectID) error + }) + if !ok || result.Err != nil || !result.Moved { + return result + } + if !result.DocumentPosition.Document.Valid() { + result.Err = atspi.ErrStaleObject + return result + } + focus := worker.focus() + if focus != result.DocumentFocus { + if cursor == nil { + result.Err = errors.New("document focus changed") + return result + } + reference, err := cursor.tree.Reference(focus) + if err != nil { + result.Err = err + return result + } + document, err := cursor.tree.DocumentContext(reference) + if err != nil { + result.Err = err + return result + } + if document.TopLevel != result.DocumentPosition.Document { + result.Err = errors.New("document focus changed") + return result + } + result.DocumentFocus = focus + } + if !focus.Valid() { + result.Err = atspi.ErrStaleObject + return result + } + deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + result.CaretErr = navigator.SetDocumentPosition(deadline, result.DocumentPosition.Object, focus) + return result +} diff --git a/internal/review/document_worker_test.go b/internal/review/document_worker_test.go new file mode 100644 index 0000000..dc95d50 --- /dev/null +++ b/internal/review/document_worker_test.go @@ -0,0 +1,927 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type documentPlacement struct { + position atspi.ObjectReference + focus atspi.ObjectID +} + +type boundaryNavigator struct { + *fakeCapturer + caretTextReader + position atspi.ObjectReference + focus atspi.ObjectID + offset int +} + +func (navigator *boundaryNavigator) SetDocumentCaretPosition(_ context.Context, position atspi.ObjectReference, focus atspi.ObjectID, offset int) error { + navigator.position, navigator.focus, navigator.offset = position, focus, offset + return nil +} + +func TestDocumentTimeoutRetiresQueuedNavigation(t *testing.T) { + text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} + capturer := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + worker.SyncFocus(webID("/text")) + if err := worker.MoveDocumentCaret(CaretNextLine); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + for range 8 { + if err := worker.MoveDocumentCaret(CaretNextLine); err != nil { + t.Fatal(err) + } + } + if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { + t.Fatal(err) + } + if err := worker.ActivateDocumentCaret(); err != nil { + t.Fatal(err) + } + call.result <- captureResult{err: context.DeadlineExceeded} + if result := waitResult(t, worker); !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatalf("initial result = %+v", result) + } + // A fresh command must recover without eight more failed captures, a + // heading move, or activation of a target selected before the timeout. + if err := worker.MoveDocumentCaret(CaretDocumentStart); err != nil { + t.Fatal(err) + } + call = <-capturer.calls + call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} + result := waitResult(t, worker) + if result.Err != nil || !result.Moved || result.DocumentCaretCommand != CaretDocumentStart || result.DocumentCaretText != "First line" { + t.Fatalf("recovery result = %+v", result) + } +} + +func TestDocumentSuccessfulQueuedStepsRemainOrdered(t *testing.T) { + text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} + capturer := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + worker.SyncFocus(webID("/text")) + if err := worker.MoveDocumentCaret(CaretNextCharacter); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + for range 2 { + if err := worker.MoveDocumentCaret(CaretNextCharacter); err != nil { + t.Fatal(err) + } + } + call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} + for index, expected := range []string{"i", "r", "s"} { + result := waitResult(t, worker) + if result.Err != nil || !result.Moved || result.DocumentCaretText != expected || result.DocumentPosition.Offset != index+1 { + t.Fatalf("step %d = %+v", index, result) + } + } +} + +func TestDocumentOldTimeoutPreservesNewPageCommands(t *testing.T) { + text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} + capturer := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + worker.SyncFocus(webID("/text")) + if err := worker.MoveDocumentCaret(CaretNextLine); err != nil { + t.Fatal(err) + } + oldCall := <-capturer.calls + worker.ResetDocument(webID("/text")) + if err := worker.MoveDocumentCaret(CaretDocumentStart); err != nil { + t.Fatal(err) + } + oldCall.result <- captureResult{err: context.DeadlineExceeded} + oldResult := waitResult(t, worker) + if worker.CurrentDocumentResult(oldResult) { + t.Fatal("old timeout retained current page ownership") + } + call := <-capturer.calls + call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} + result := waitResult(t, worker) + if result.Err != nil || !result.Moved || result.DocumentCaretCommand != CaretDocumentStart || !worker.CurrentDocumentResult(result) { + t.Fatalf("new page result = %+v", result) + } +} + +func TestDocumentCaretMovePlacesNativeCaretForTabTraversal(t *testing.T) { + for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine, CaretDocumentStart, CaretDocumentEnd, CaretNextCharacter, CaretNextWord, CaretLineStart, CaretLineEnd} { + text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} + navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: text} + worker, err := NewWorker(navigator, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + cursor := newCaretTextCursor(t, text) + if command == CaretDocumentStart || command == CaretPreviousLine { + if _, err := cursor.MoveCaret(context.Background(), text, CaretDocumentEnd); err != nil { + t.Fatal(err) + } + } + result, _ := worker.moveDocumentCaret(context.Background(), cursor, command, worker.documentEpoch.Load()) + if result.Err != nil || result.CaretErr != nil || !result.Moved || + navigator.position != result.DocumentPosition.Object || navigator.focus != webID("/text") || navigator.offset != result.DocumentPosition.Offset { + t.Fatalf("command %v: result=%+v, placement=%+v", command, result, navigator) + } + if (command == CaretNextLine || command == CaretPreviousLine || command == CaretDocumentStart || command == CaretDocumentEnd) && (result.DocumentCaretStart != 0 || result.DocumentCaretEnd != len(text[result.DocumentPosition.Object.ID()])) { + t.Fatalf("command %v: native text range = %d:%d", command, result.DocumentCaretStart, result.DocumentCaretEnd) + } + } +} + +type recordingDocumentActivator struct { + *fakeCapturer + target atspi.ObjectReference + focus atspi.ObjectID +} + +func (activator *recordingDocumentActivator) ActivateDocumentObject(_ context.Context, target atspi.ObjectReference, focus atspi.ObjectID) error { + activator.target, activator.focus = target, focus + return nil +} + +func TestDocumentActivationUsesBrowseCaretInsteadOfFocusedTopic(t *testing.T) { + node := func(path, role string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, path, role, "", "") + snapshot.BusName = webID(path).BusName + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "document web", "/topic", "/new_posts"), + node("/topic", "link"), + node("/new_posts", "link", "/new_posts_text"), + node("/new_posts_text", "static"), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(context.Background(), webID("/topic")); err != nil { + t.Fatal(err) + } + staticRef, err := tree.Reference(webID("/new_posts_text")) + if err != nil { + t.Fatal(err) + } + cursor.places[len(cursor.places)-1].object = staticRef + cursor.places[len(cursor.places)-1].origin = CaretNavigation + activator := &recordingDocumentActivator{fakeCapturer: &fakeCapturer{}} + worker, err := NewWorker(activator, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/topic")) + result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0) + if result.Err != nil || activator.target.ID() != webID("/new_posts") || activator.focus != webID("/topic") { + t.Fatalf("activation result=%+v target=%v focus=%v", result, activator.target.ID(), activator.focus) + } +} + +type waitingCaretReader struct { + *fakeCapturer + deadline time.Time +} + +type refreshingCaretReader struct { + *fakeCapturer + text caretTextReader + staleOnce bool +} + +type invalidatingCaretReader struct { + *fakeCapturer + text caretTextReader + tree *atspi.AccessibleTree +} + +func (reader *invalidatingCaretReader) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + result, err := reader.text.ReadDocumentCaret(ctx, reference, offset, unit) + reader.tree.Invalidate(reference.ID()) + return result, err +} + +func TestDocumentCaretSpeaksReadTextWhenAncestorDisappears(t *testing.T) { + text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"} + cursor := newCaretTextCursor(t, text) + reader := &invalidatingCaretReader{fakeCapturer: &fakeCapturer{}, text: text, tree: cursor.tree} + worker, err := NewWorker(reader, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0) + if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 { + t.Fatalf("stale ancestor after caret read: %+v", result) + } +} + +func (reader *refreshingCaretReader) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + if reader.staleOnce { + reader.staleOnce = false + return atspi.DocumentCaretText{}, atspi.ErrStaleObject + } + return reader.text.ReadDocumentCaret(ctx, reference, offset, unit) +} + +func TestDocumentCaretRetriesStaleRecaptureWithoutSkipping(t *testing.T) { + text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"} + oldCursor := newCaretTextCursor(t, text) + oldCursor.tree.Invalidate(webID("/text")) + first := newCaretTextCursor(t, text).tree + fresh := newCaretTextCursor(t, text).tree + reader := &refreshingCaretReader{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, text: text, staleOnce: true} + worker, err := NewWorker(reader, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + done := make(chan Result, 1) + go func() { + result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0) + done <- result + }() + initial := <-reader.calls + initial.result <- captureResult{tree: first} + retry := <-reader.calls + if retry.reviewFocus != webID("/text") { + t.Fatalf("retry focus = %v", retry.reviewFocus) + } + retry.result <- captureResult{tree: fresh} + result := <-done + if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 { + t.Fatalf("stale recapture retry: %+v", result) + } +} + +func (reader *waitingCaretReader) ReadDocumentCaret(ctx context.Context, _ atspi.ObjectReference, _ int, _ atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + reader.deadline, _ = ctx.Deadline() + <-ctx.Done() + return atspi.DocumentCaretText{}, ctx.Err() +} + +func TestDocumentCaretNavigationDeadlinePreservesPosition(t *testing.T) { + text := caretTextReader{webID("/text"): "Alpha beta", webID("/next"): "Next"} + cursor := newCaretTextCursor(t, text) + reader := &waitingCaretReader{fakeCapturer: &fakeCapturer{}} + worker, err := NewWorker(reader, time.Millisecond) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextWord, 0) + parentDeadline, _ := ctx.Deadline() + if !errors.Is(result.Err, context.DeadlineExceeded) || result.Moved || !reader.deadline.Before(parentDeadline) { + t.Fatalf("navigation deadline: result %+v, deadline %v", result, reader.deadline) + } + move, err := cursor.MoveCaret(context.Background(), text, CaretNextCharacter) + if err != nil || move.Text != "l" || move.Position.Offset != 1 { + t.Fatalf("position changed after timeout: %+v, %v", move, err) + } +} + +func TestRecoverRemovedDocumentFocusStaysInActiveDocument(t *testing.T) { + _, cursor := webCursor(t) + if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { + t.Fatal(err) + } + nodes := webNodes() + // Recapture of a dynamic page no longer contains the old caret object. + nodes[2].Children = []atspi.ObjectID{webID("/second")} + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{nodes[2], nodes[5]}) + if err != nil { + t.Fatal(err) + } + worker, err := NewWorker(&fakeCapturer{}, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/first")) + result := Result{DocumentFocus: webID("/first"), CaptureReason: atspi.ErrObjectNotFound} + if !worker.recoverCapturedDocumentFocus(cursor, tree, &result) || result.DocumentFocus != webID("/doc") || worker.focus() != webID("/doc") { + t.Fatalf("same-document recovery failed: %+v, focus %v", result, worker.focus()) + } + worker.SyncFocus(webID("/chrome")) + result.DocumentFocus = webID("/first") + if worker.recoverCapturedDocumentFocus(cursor, tree, &result) || worker.focus() != webID("/chrome") { + t.Fatal("recovery displaced browser chrome focus") + } + worker.SyncFocus(webID("/first")) + other := nodes[8] + other.Children = nil + otherTree, err := atspi.NewAccessibleTree(webID("/other"), []atspi.TreeNode{other}) + if err != nil { + t.Fatal(err) + } + if worker.recoverCapturedDocumentFocus(cursor, otherTree, &result) || worker.focus() != webID("/first") { + t.Fatal("recovery crossed to another document") + } +} + +func TestDocumentMoveRecoversAfterDynamicObjectRemoval(t *testing.T) { + oldTree, cursor := webCursor(t) + if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { + t.Fatal(err) + } + nodes := webNodes() + nodes[2].Children = nodes[2].Children[1:] + withoutFirst := append(append([]atspi.TreeNode{}, nodes[:3]...), nodes[4:]...) + if err := oldTree.Replace(webID("/root"), withoutFirst); err != nil { + t.Fatal(err) + } + freshDocument := nodes[2] + freshDocument.Children = []atspi.ObjectID{webID("/second")} + freshTree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{freshDocument, nodes[5]}) + if err != nil { + t.Fatal(err) + } + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/first")) + type moveResult struct { + result Result + } + done := make(chan moveResult, 1) + go func() { + result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) + done <- moveResult{result: result} + }() + call := <-capturer.calls + if call.reviewFocus != webID("/first") { + t.Fatalf("capture anchor = %v", call.reviewFocus) + } + call.result <- captureResult{tree: freshTree} + result := (<-done).result + if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") || result.DocumentFocus != webID("/doc") { + t.Fatalf("document move after removal: %+v", result) + } +} + +func TestDocumentMoveRecoversFocusRemovedDuringTextRecapture(t *testing.T) { + tree, cursor := webCursor(t) + if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { + t.Fatal(err) + } + // The initial failure describes stale text, but the ensuing capture can + // discover that the old focus has since been removed altogether. + tree.InvalidateText(webID("/first")) + nodes := webNodes() + nodes[2].Children = []atspi.ObjectID{webID("/second")} + fresh, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{nodes[2], nodes[5]}) + if err != nil { + t.Fatal(err) + } + capturer := &fakeCapturer{calls: make(chan captureCall, 2)} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/first")) + done := make(chan Result, 1) + go func() { + result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) + done <- result + }() + deadline := time.After(3 * time.Second) + for { + select { + case call := <-capturer.calls: + call.result <- captureResult{tree: fresh} + case result := <-done: + if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") || result.DocumentFocus != webID("/doc") { + t.Fatalf("focus removed during recapture: %+v", result) + } + return + case <-deadline: + t.Fatal("navigation did not finish") + } + } +} + +func TestDocumentMoveRetriesStaleRecaptureWithoutSkipping(t *testing.T) { + oldTree, cursor := webCursor(t) + if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { + t.Fatal(err) + } + oldTree.Invalidate(webID("/first")) + first, _ := webCursor(t) + first.Invalidate(webID("/first")) + fresh, _ := webCursor(t) + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/first")) + done := make(chan Result, 1) + go func() { + result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) + done <- result + }() + initial := <-capturer.calls + initial.result <- captureResult{tree: first} + retry := <-capturer.calls + if retry.reviewFocus != webID("/first") { + t.Fatalf("retry focus = %v", retry.reviewFocus) + } + retry.result <- captureResult{tree: fresh} + result := <-done + if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") { + t.Fatalf("stale heading recapture retry: %+v", result) + } +} + +func TestDocumentMoveRetriesStaleSnapshotWithoutSkipping(t *testing.T) { + oldTree, cursor := webCursor(t) + if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { + t.Fatal(err) + } + oldTree.InvalidateSnapshot(webID("/first")) + first, _ := webCursor(t) + first.InvalidateSnapshot(webID("/first")) + fresh, _ := webCursor(t) + capturer := &fakeCapturer{calls: make(chan captureCall, 2)} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/first")) + done := make(chan Result, 1) + go func() { + result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) + done <- result + }() + initial := <-capturer.calls + initial.result <- captureResult{tree: first} + retry := <-capturer.calls + if retry.reviewFocus != webID("/first") { + t.Fatalf("retry focus = %v", retry.reviewFocus) + } + retry.result <- captureResult{tree: fresh} + result := <-done + if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") { + t.Fatalf("stale snapshot recapture retry: %+v", result) + } +} + +func TestDocumentCaretRetriesStaleSnapshotWithoutSkipping(t *testing.T) { + text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"} + oldCursor := newCaretTextCursor(t, text) + oldCursor.tree.InvalidateSnapshot(webID("/text")) + first := newCaretTextCursor(t, text).tree + first.InvalidateSnapshot(webID("/text")) + fresh := newCaretTextCursor(t, text).tree + reader := &refreshingCaretReader{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 2)}, text: text} + worker, err := NewWorker(reader, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/text")) + done := make(chan Result, 1) + go func() { + result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0) + done <- result + }() + initial := <-reader.calls + initial.result <- captureResult{tree: first} + retry := <-reader.calls + if retry.reviewFocus != webID("/text") { + t.Fatalf("retry focus = %v", retry.reviewFocus) + } + retry.result <- captureResult{tree: fresh} + result := <-done + if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 { + t.Fatalf("stale snapshot caret retry: %+v", result) + } +} + +type fakeDocumentNavigator struct { + *fakeCapturer + calls chan documentPlacement + placementErr chan error +} + +func newFakeDocumentNavigator() *fakeDocumentNavigator { + return &fakeDocumentNavigator{ + fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, + calls: make(chan documentPlacement, 1), + placementErr: make(chan error, 1), + } +} + +func TestWorkerJumpsToLoadedDocumentFragment(t *testing.T) { + node := func(path, name, role, id string, children ...string) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, role, "", "") + snapshot.BusName = webID(path).BusName + snapshot.ElementID = id + result := atspi.TreeNode{Snapshot: snapshot} + for _, child := range children { + result.Children = append(result.Children, webID(child)) + } + return result + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "Forum", "document web", "", "/post"), + node("/post", "", "section", "p71", "/heading"), + node("/heading", "#71 User", "heading", "", "/permalink"), + node("/permalink", "#71", "link", ""), + }) + if err != nil { + t.Fatal(err) + } + navigator := newFakeDocumentNavigator() + worker, err := NewWorker(navigator, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/doc")) + results := make(chan Result, 1) + go func() { + result, _ := worker.jumpDocumentFragment(context.Background(), nil, "p71", worker.documentEpoch.Load()) + results <- result + }() + capture := <-navigator.fakeCapturer.calls + if capture.reviewFocus != webID("/doc") { + t.Fatalf("capture focus = %v", capture.reviewFocus) + } + capture.result <- captureResult{tree: tree} + placement := <-navigator.calls + if placement.position.ID() != webID("/permalink") || placement.focus != webID("/doc") { + t.Fatalf("fragment placement = %+v", placement) + } + result := <-results + if result.Err != nil || result.CaretErr != nil || !result.Moved || result.DocumentPosition.Snapshot.Name != "#71" { + t.Fatalf("fragment result = %+v", result) + } +} + +func (navigator *fakeDocumentNavigator) SetDocumentPosition(ctx context.Context, position atspi.ObjectReference, focus atspi.ObjectID) error { + call := documentPlacement{position: position, focus: focus} + select { + case navigator.calls <- call: + select { + case err := <-navigator.placementErr: + return err + default: + return nil + } + default: + return errors.New("unexpected duplicate document placement") + } +} + +func TestWorkerRetriesStaleDocumentCaretOnSameTarget(t *testing.T) { + for _, failure := range []error{atspi.ErrStaleObject, atspi.ErrObjectNotFound} { + t.Run(failure.Error(), func(t *testing.T) { + tree, _ := webCursor(t) + navigator := newFakeDocumentNavigator() + worker, cancel := startWorker(t, navigator, time.Second) + defer worker.Stop(context.Background()) + defer cancel() + worker.SyncFocus(webID("/first")) + navigator.placementErr <- failure + if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { + t.Fatal(err) + } + initial := <-navigator.fakeCapturer.calls + initial.result <- captureResult{tree: tree} + first := <-navigator.calls + if first.position.ID() != webID("/second") { + t.Fatalf("first placement = %+v", first) + } + retry := <-navigator.fakeCapturer.calls + if retry.reviewFocus != webID("/first") { + t.Fatalf("retry focus = %v", retry.reviewFocus) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + tree.Invalidate(root.ID()) + if err := tree.Replace(root.ID(), webNodes()); err != nil { + t.Fatal(err) + } + retry.result <- captureResult{tree: tree} + second := <-navigator.calls + if second.position.ID() != first.position.ID() || second.position == first.position { + t.Fatalf("retry must rebind same target: first=%+v second=%+v", first, second) + } + result := waitResult(t, worker) + if result.Err != nil || result.CaretErr != nil || result.DocumentPosition.Object != second.position { + t.Fatalf("retry result = %+v", result) + } + if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { + t.Fatal(err) + } + <-navigator.calls + next := waitResult(t, worker) + if next.Err != nil || next.CaretErr != nil || next.CaptureReason != nil || next.DocumentPosition.Object.ID() != webID("/first") { + t.Fatalf("next move after retry = %+v", next) + } + }) + } +} + +func TestWorkerDocumentNavigation(t *testing.T) { + tree, _ := webCursor(t) + capturer := &fakeCapturer{calls: make(chan captureCall, 2)} + worker, cancel := startWorker(t, capturer, time.Second) + defer worker.Stop(context.Background()) + defer cancel() + worker.SyncFocus(webID("/first")) + if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + if call.reviewFocus != webID("/first") { + t.Fatal("capture not scoped to focus") + } + call.result <- captureResult{tree: tree} + if r := waitResult(t, worker); r.Err != nil || !errors.Is(r.CaptureReason, ErrNoCurrent) || + !r.Moved || r.DocumentPosition.Object.ID() != webID("/second") { + t.Fatalf("heading: %+v", r) + } + for _, tc := range []struct { + target DocumentTarget + back bool + path string + moved bool + wrapped bool + }{ + {DocumentLink, false, "/link", true, false}, + {DocumentLink, false, "/link", true, true}, + {DocumentButton, false, "/button", true, false}, + {DocumentCheckBox, false, "/checkbox", true, false}, + {DocumentList, false, "/list", true, false}, + {DocumentSeparator, false, "/separator", true, false}, + {DocumentSeparator, false, "/separator", true, true}, + {DocumentHeading, true, "/second", true, false}, + {DocumentHeading, true, "/first", true, false}, + } { + worker.MoveDocument(tc.target, 0, tc.back) + if r := waitResult(t, worker); r.Err != nil || r.Moved != tc.moved || r.DocumentWrapped != tc.wrapped || + r.DocumentPosition.Object.ID() != webID(tc.path) { + t.Fatalf("navigation: %+v", r) + } + } + worker.SyncFocus(webID("/chrome")) + worker.MoveDocument(DocumentHeading, 0, false) + if r := waitResult(t, worker); !errors.Is(r.Err, atspi.ErrNotInDocument) { + t.Fatalf("chrome: %+v", r) + } + select { + case <-capturer.calls: + t.Fatal("unexpected recapture") + default: + } +} + +func TestWorkerDocumentLoadRetiresOutstandingResult(t *testing.T) { + tree, _ := webCursor(t) + capturer := &fakeCapturer{calls: make(chan captureCall, 2)} + worker, cancel := startWorker(t, capturer, time.Second) + defer worker.Stop(context.Background()) + defer cancel() + worker.SyncFocus(webID("/first")) + worker.MoveDocument(DocumentHeading, 0, false) + call := <-capturer.calls + worker.ResetDocument(webID("/first")) + call.result <- captureResult{tree: tree} + if result := waitResult(t, worker); worker.CurrentDocumentResult(result) { + t.Fatal("old page result accepted after same-path load") + } + worker.MoveDocument(DocumentHeading, 0, false) + select { + case call = <-capturer.calls: + call.result <- captureResult{tree: tree} + case <-time.After(time.Second): + t.Fatal("new page reused the old cursor instead of recapturing") + } + if result := waitResult(t, worker); !worker.CurrentDocumentResult(result) || result.Err != nil || result.DocumentPosition.Object.ID() != webID("/second") { + t.Fatalf("new page navigation = %+v", result) + } +} + +func TestWorkerDocumentCaptureUsesLatestFocus(t *testing.T) { + tree, _ := webCursor(t) + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer worker.Stop(context.Background()) + defer cancel() + worker.SyncFocus(webID("/first")) + worker.MoveDocument(DocumentHeading, 0, false) + call := <-capturer.calls + worker.SyncFocus(webID("/chrome")) + call.result <- captureResult{tree: tree} + r := waitResult(t, worker) + if r.DocumentFocus != webID("/chrome") || !errors.Is(r.Err, atspi.ErrNotInDocument) { + t.Fatalf("stale focus used: %+v", r) + } +} + +func TestWorkerDocumentRecapturePreservesPosition(t *testing.T) { + tree, _ := webCursor(t) + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer worker.Stop(context.Background()) + defer cancel() + worker.SyncFocus(webID("/first")) + worker.MoveDocument(DocumentHeading, 0, false) + call := <-capturer.calls + call.result <- captureResult{tree: tree} + waitResult(t, worker) + tree.InvalidateText(webID("/link")) + worker.MoveDocument(DocumentHeading, 0, true) + call = <-capturer.calls + nodes := webNodes() + nodes[4].Geometry = atspi.Geometry{VisibilityKnown: true} + if err := tree.Replace(webID("/root"), nodes); err != nil { + t.Fatal(err) + } + call.result <- captureResult{tree: tree} + r := waitResult(t, worker) + if r.Err != nil || !errors.Is(r.CaptureReason, atspi.ErrStaleText) || !r.Moved || + r.DocumentWrapped || r.DocumentPosition.Object.ID() != webID("/first") { + t.Fatalf("recapture lost place: %+v", r) + } +} + +func TestWorkerPlaceDocumentAcceptsSameDocumentFocusTransition(t *testing.T) { + _, cursor := webCursor(t) + ctx := context.Background() + _, err := cursor.SyncFocus(ctx, webID("/first")) + if err != nil { + t.Fatal(err) + } + move, err := cursor.Move(ctx, DocumentHeading, 2, false) + if err != nil || !move.Moved { + t.Fatalf("move: %+v err=%v", move, err) + } + worker, err := NewWorker(newFakeDocumentNavigator(), time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/button")) + result := worker.placeDocument(ctx, cursor, Result{ + DocumentTarget: DocumentHeading, + DocumentFocus: webID("/first"), + DocumentPosition: move.Position, + Moved: true, + }) + if result.Err != nil || result.CaretErr != nil { + t.Fatalf("same-document transition rejected: %+v", result) + } + if result.DocumentFocus != webID("/button") { + t.Fatalf("document focus = %v, want /button", result.DocumentFocus) + } + navigator, _ := worker.capturer.(*fakeDocumentNavigator) + select { + case call := <-navigator.calls: + if call.position != move.Position.Object || call.focus != webID("/button") { + t.Fatalf("placement = %+v", call) + } + default: + t.Fatal("document placement was not called") + } +} + +func TestWorkerPlaceDocumentRejectsCrossDocumentFocusTransition(t *testing.T) { + _, cursor := webCursor(t) + ctx := context.Background() + _, err := cursor.SyncFocus(ctx, webID("/first")) + if err != nil { + t.Fatal(err) + } + move, err := cursor.Move(ctx, DocumentHeading, 2, false) + if err != nil || !move.Moved { + t.Fatalf("move: %+v err=%v", move, err) + } + navigator := newFakeDocumentNavigator() + worker, err := NewWorker(navigator, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/other")) + result := worker.placeDocument(ctx, cursor, Result{ + DocumentTarget: DocumentHeading, + DocumentFocus: webID("/first"), + DocumentPosition: move.Position, + Moved: true, + }) + if result.Err == nil || result.Err.Error() != "document focus changed" { + t.Fatalf("cross-document transition accepted: %+v", result) + } + select { + case <-navigator.calls: + t.Fatal("cross-document placement was called") + default: + } +} + +func TestWorkerPlaceDocumentPreservesErrorResult(t *testing.T) { + _, cursor := webCursor(t) + navigator := newFakeDocumentNavigator() + worker, err := NewWorker(navigator, time.Second) + if err != nil { + t.Fatal(err) + } + result := Result{Err: context.Canceled, DocumentFocus: webID("/first"), Moved: true} + got := worker.placeDocument(context.Background(), cursor, result) + if !errors.Is(got.Err, context.Canceled) { + t.Fatalf("result error = %v, want canceled", got.Err) + } + select { + case <-navigator.calls: + t.Fatal("failed navigation placement was called") + default: + } +} + +func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) { + for _, kind := range []string{"semantics", "children"} { + t.Run(kind, func(t *testing.T) { + text := caretTextReader{webID("/text"): "First\nSecond\nThird", webID("/next"): "End"} + cursor := newCaretTextCursor(t, text) + navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} + worker, err := NewWorker(navigator, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/doc")) + ctx := context.Background() + for _, want := range []string{"First\n", "Second\n"} { + result, next := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load()) + cursor = next + if result.Err != nil || result.DocumentCaretText != want { + t.Fatalf("initial navigation = %+v, want %q", result, want) + } + } + docRef, _ := cursor.tree.Reference(webID("/doc")) + doc, err := cursor.tree.Document(ctx, docRef) + if err != nil { + t.Fatal(err) + } + nodes := make([]atspi.TreeNode, 0, len(doc.Nodes)) + for _, node := range doc.Nodes { + nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children, Geometry: node.Geometry, Text: node.Text}) + } + wantReason := atspi.ErrStaleSnapshot + if kind == "children" { + cursor.tree.MarkChildrenChanged(webID("/doc")) + wantReason = atspi.ErrIncompleteTree + } else { + cursor.tree.InvalidateSnapshot(webID("/text")) + } + go func() { + call := <-navigator.calls + err := cursor.tree.Replace(webID("/doc"), nodes) + call.result <- captureResult{tree: cursor.tree, err: err} + }() + result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load()) + if result.Err != nil || !errors.Is(result.CaptureReason, wantReason) || result.DocumentCaretText != "Third" { + t.Fatalf("semantic refresh restarted or skipped reading: %+v", result) + } + }) + } +} diff --git a/internal/review/flat.go b/internal/review/flat.go new file mode 100644 index 0000000..ebca36a --- /dev/null +++ b/internal/review/flat.go @@ -0,0 +1,391 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "sort" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// FlatUnit describes sequential reading, independently of keyboard layout. +type FlatUnit int + +const ( + Line FlatUnit = iota + 1 + Word + Character + WindowStart + WindowEnd + WordAbove + WordBelow + LineEnd +) + +// FlatPosition is owned text at the review location. Offset counts Unicode +// code points in Object's AT-SPI text; synthetic accessible names have no +// offset. Bounds belong to the whole captured zone, never guessed characters. +type FlatPosition struct { + Text string + Object atspi.ObjectID + Offset int + HasOffset bool + Bounds atspi.Rectangle +} + +type flatLocation struct{ line, zone, character int } + +// FlatCursor is owned by the review worker. It rebuilds from one atomic owned +// window snapshot before every command, preserving an independent location by +// object and character offset when focus has not changed. No native queries or +// output calls occur here. Failed refreshes leave the previous location intact +// but return an error, so stale content is never presented as current. +type FlatCursor struct { + lines []flatLine + location flatLocation + focus atspi.ObjectID + tree *atspi.AccessibleTree + references map[atspi.ObjectID]atspi.ObjectReference + parents map[atspi.ObjectID]atspi.ObjectID + verticalTargetX int64 + hasVerticalTarget bool + started bool +} + +func (cursor *FlatCursor) Refresh(ctx context.Context, tree *atspi.AccessibleTree, focus atspi.ObjectID) error { + if tree == nil { + return ErrNoCurrent + } + snapshot, err := tree.ReviewWindow(ctx, focus) + if err != nil { + return err + } + nodes := snapshot.Nodes + lines, err := buildFlatLines(ctx, nodes) + if err != nil { + return err + } + next := FlatCursor{lines: lines, focus: focus, tree: tree, references: snapshot.References, + parents: make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))} + for _, n := range nodes { + for _, child := range n.Children { + next.parents[child] = n.Snapshot.ID() + } + } + // Exact identity takes priority over a displayed descendant of focus. + if !next.seek(focus, 0, false) { + next.seekDescendant(focus) + } + for _, n := range nodes { + if n.Snapshot.ID() == focus && n.Text.CaretKnown { + next.seek(focus, n.Text.CaretOffset, true) + break + } + } + if cursor.focus == focus && cursor.tree == tree && len(cursor.lines) > 0 { + old := cursor.position(Character) + if cursor.references[old.Object] == next.references[old.Object] { + if next.seek(old.Object, old.Offset, old.HasOffset) { + next.verticalTargetX = cursor.verticalTargetX + next.hasVerticalTarget = cursor.hasVerticalTarget + } + } + } + next.started = cursor.started && cursor.focus == focus + *cursor = next + return nil +} + +func (cursor *FlatCursor) seekDescendant(object atspi.ObjectID) bool { + for li, line := range cursor.lines { + for zi, zone := range line.zones { + for id := cursor.parents[zone.object]; id.Valid(); id = cursor.parents[id] { + if id == object { + cursor.location = flatLocation{line: li, zone: zi} + return true + } + } + } + } + return false +} + +func (cursor *FlatCursor) seek(object atspi.ObjectID, offset int, hasOffset bool) bool { + for li, line := range cursor.lines { + for zi, zone := range line.zones { + if zone.object != object { + continue + } + if hasOffset && (!zone.hasOffset || offset < zone.start || offset >= zone.start+len(zone.runes)) { + continue + } + character := 0 + if hasOffset { + character = offset - zone.start + } else if !zone.hasOffset && offset >= 0 && offset < len(zone.runes) { + character = offset + } + cursor.location = flatLocation{li, zi, character} + return true + } + } + // End-of-line/text caret positions are blank, but still have an exact + // source offset. Prefer an actual character in a following range above. + if hasOffset { + for li, line := range cursor.lines { + for zi, zone := range line.zones { + if zone.object == object && zone.hasOffset && offset == zone.start+len(zone.runes) { + cursor.location = flatLocation{li, zi, len(zone.runes)} + return true + } + } + } + } + return false +} + +// Read moves by -1, 0, or +1 units and returns the resulting text. It crosses +// zone and line boundaries, but never wraps from the window's end to its start. +// A boundary returns moved=false and leaves the location unchanged. +func (cursor *FlatCursor) Read(unit FlatUnit, step int) (FlatPosition, bool, error) { + if unit < Line || unit > LineEnd || step < -1 || step > 1 || (unit > Character && step != 0) { + return FlatPosition{}, false, ErrInvalidDirection + } + if len(cursor.lines) == 0 { + return FlatPosition{}, false, ErrNoReviewContent + } + old := cursor.location + switch unit { + case WindowStart: + cursor.location = flatLocation{} + if cursor.location != old { + cursor.hasVerticalTarget = false + } + return cursor.position(Line), cursor.location != old, nil + case WindowEnd: + line := len(cursor.lines) - 1 + zone := len(cursor.lines[line].zones) - 1 + character := len(cursor.lines[line].zones[zone].runes) - 1 + cursor.location = flatLocation{line: line, zone: zone, character: character} + if cursor.location != old { + cursor.hasVerticalTarget = false + } + return cursor.position(Line), cursor.location != old, nil + case WordAbove: + if cursor.moveVertically(-1, cursor.verticalX()) { + return cursor.position(Word), true, nil + } + return cursor.position(Word), false, nil + case WordBelow: + if cursor.moveVertically(1, cursor.verticalX()) { + return cursor.position(Word), true, nil + } + return cursor.position(Word), false, nil + case LineEnd: + line := cursor.location.line + zone := len(cursor.lines[line].zones) - 1 + character := len(cursor.lines[line].zones[zone].runes) - 1 + cursor.location = flatLocation{line: line, zone: zone, character: character} + if cursor.location != old { + cursor.hasVerticalTarget = false + } + return cursor.position(Character), cursor.location != old, nil + } + if step != 0 { + switch unit { + case Line: + line := old.line + step + if line >= 0 && line < len(cursor.lines) { + cursor.location = flatLocation{line: line} + } + case Character: + cursor.advanceCharacter(step) + case Word: + cursor.advanceWord(step) + } + } + moved := cursor.location != old + if moved { + cursor.hasVerticalTarget = false + } + return cursor.position(unit), moved, nil +} + +func (cursor *FlatCursor) verticalX() int64 { + if !cursor.hasVerticalTarget { + cursor.verticalTargetX = characterCenterX(cursor.zone(), cursor.location.character) + cursor.hasVerticalTarget = true + } + return cursor.verticalTargetX +} + +func (cursor *FlatCursor) moveVertically(step int, targetX int64) bool { + destinationLine := cursor.location.line + step + if destinationLine < 0 || destinationLine >= len(cursor.lines) { + return false + } + zones := append([]flatZone(nil), cursor.lines[destinationLine].zones...) + sort.SliceStable(zones, func(i, j int) bool { return zones[i].bounds.X < zones[j].bounds.X }) + for _, zone := range zones { + for character := range zone.runes { + if characterRightX(zone, character) >= targetX { + cursor.location = flatLocation{line: destinationLine, zone: zoneIndexInLine(cursor.lines[destinationLine], zone), character: character} + return true + } + } + } + zone := zones[len(zones)-1] + cursor.location = flatLocation{line: destinationLine, zone: zoneIndexInLine(cursor.lines[destinationLine], zone), character: len(zone.runes) - 1} + return true +} + +func zoneIndexInLine(line flatLine, zone flatZone) int { + for index, candidate := range line.zones { + if candidate.object == zone.object && candidate.start == zone.start { + return index + } + } + return 0 +} + +func characterCenterX(zone flatZone, character int) int64 { + if len(zone.runes) == 0 { + return int64(zone.bounds.X) + } + return int64(zone.bounds.X) + int64(zone.bounds.Width)*int64(2*character+1)/(int64(2*len(zone.runes))) +} + +func characterRightX(zone flatZone, character int) int64 { + if len(zone.runes) == 0 { + return int64(zone.bounds.X) + } + return int64(zone.bounds.X) + int64(zone.bounds.Width)*int64(character+1)/int64(len(zone.runes)) +} + +func (cursor *FlatCursor) zone() flatZone { + return cursor.lines[cursor.location.line].zones[cursor.location.zone] +} + +func (cursor *FlatCursor) advanceZone(step int) bool { + next := cursor.location + next.zone += step + if next.zone < 0 { + next.line-- + if next.line < 0 { + return false + } + next.zone = len(cursor.lines[next.line].zones) - 1 + } else if next.zone >= len(cursor.lines[next.line].zones) { + next.line++ + if next.line >= len(cursor.lines) { + return false + } + next.zone = 0 + } + next.character = 0 + cursor.location = next + return true +} + +func (cursor *FlatCursor) advanceCharacter(step int) bool { + next := cursor.location.character + step + if next >= 0 && next < len(cursor.zone().runes) { + cursor.location.character = next + return true + } + if !cursor.advanceZone(step) { + return false + } + if step < 0 { + cursor.location.character = len(cursor.zone().runes) - 1 + } + return true +} + +func (cursor *FlatCursor) advanceWord(step int) { + old := cursor.location + zone := cursor.zone() + index := wordIndex(zone, old.character) + if next := index + step; next >= 0 && next < len(zone.words) { + cursor.location.character = zone.words[next][0] + return + } + for cursor.advanceZone(step) { + zone = cursor.zone() + if len(zone.words) == 0 { + if isBlankLineZone(zone) { + cursor.location.character = 0 + return + } + continue + } + index = 0 + if step < 0 { + index = len(zone.words) - 1 + } + cursor.location.character = zone.words[index][0] + return + } + cursor.location = old +} + +func isBlankLineZone(zone flatZone) bool { + if len(zone.runes) == 0 { + return false + } + for _, r := range zone.runes { + if r != '\n' && r != '\r' { + return false + } + } + return true +} + +func wordIndex(zone flatZone, character int) int { + for i, span := range zone.words { + if character < span[1] { + return i + } + } + return len(zone.words) - 1 +} + +func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition { + zone := cursor.zone() + start, end := cursor.location.character, min(cursor.location.character+1, len(zone.runes)) + text := "" + switch unit { + case Line: + var parts []string + for _, z := range cursor.lines[cursor.location.line].zones { + parts = append(parts, z.text) + } + text = strings.Join(parts, " ") + case Word: + if index := wordIndex(zone, start); index >= 0 { + start, end = zone.words[index][0], zone.words[index][1] + } + } + if unit != Line { + text = string(zone.runes[start:end]) + } + return FlatPosition{Text: text, Object: zone.object, Offset: zone.start + start, + HasOffset: zone.hasOffset, Bounds: zone.bounds} +} diff --git a/internal/review/flat_layout.go b/internal/review/flat_layout.go new file mode 100644 index 0000000..458246e --- /dev/null +++ b/internal/review/flat_layout.go @@ -0,0 +1,278 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "sort" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +var ( + ErrNoReviewContent = errors.New("no visible content for flat review") + ErrLimitedReview = errors.New("visible text exceeds flat review capture limits") +) + +const ( + maxFlatZones = 16384 + maxFlatTextBytes = 1 << 20 +) + +type flatZone struct { + object atspi.ObjectID + parent atspi.ObjectID + role, parentRole string + text string + start int + hasOffset bool + bounds atspi.Rectangle + runes []rune + words [][2]int +} + +type flatLine struct{ zones []flatZone } + +// buildFlatLines uses native visual ranges, followed by stable screen order. +// Like Python's default geometric rule, line membership requires overlapping +// vertical extents and centres within five pixels. Compare to the first zone +// so a staircase of overlapping objects cannot merge separate text rows. +func buildFlatLines(ctx context.Context, nodes []atspi.TreeNode) ([]flatLine, error) { + parents, nodeClips, hidden := flat_node_visibility(nodes) + zones, err := collect_flat_zones(ctx, nodes, parents, nodeClips, hidden) + if err != nil { + return nil, err + } + return arrange_flat_lines(ctx, nodes, zones) +} + +func sameFlatLine(a, b flatZone) bool { + if a.role == "scroll bar" || b.role == "scroll bar" { + return a.object == b.object + } + if a.parentRole == "menu bar" || b.parentRole == "menu bar" { + return a.parent == b.parent + } + ay, by := int64(a.bounds.Y), int64(b.bounds.Y) + ah, bh := int64(a.bounds.Height), int64(b.bounds.Height) + if max(ay, by) >= min(ay+ah, by+bh) { + return false + } + delta := (2*ay + ah) - (2*by + bh) + return delta >= -10 && delta <= 10 +} + +// flat_node_visibility follows parent clipping before collecting any text. +func flat_node_visibility(nodes []atspi.TreeNode) (map[atspi.ObjectID]atspi.Snapshot, map[atspi.ObjectID]atspi.Rectangle, map[atspi.ObjectID]bool) { + parents := make(map[atspi.ObjectID]atspi.Snapshot, len(nodes)) + clips := make(map[atspi.ObjectID]atspi.Rectangle, len(nodes)) + nodeClips := make(map[atspi.ObjectID]atspi.Rectangle, len(nodes)) + hidden := make(map[atspi.ObjectID]bool, len(nodes)) + for _, node := range nodes { + id := node.Snapshot.ID() + parent := parents[id].ID() + g := node.Geometry + hidden[id] = hidden[parent] || g.VisibilityKnown && (!g.Visible || !g.Showing) + clip := clips[parent] + nodeClip := clip + if g.BoundsKnown { + if clip.Valid() { + var ok bool + nodeClip, ok = clip.Intersection(g.Bounds) + if node.Snapshot.Role != "page tab" { + hidden[id] = hidden[id] || !ok + } + } else { + nodeClip = g.Bounds + } + } + nodeClips[id] = nodeClip + clips[id] = nodeClip + if node.Snapshot.Role == "page tab" && !hidden[id] { + // A tab header is not the viewport of its displayed page. + clips[id] = clip + } + for _, child := range node.Children { + parents[child] = node.Snapshot + } + } + return parents, nodeClips, hidden +} + +// collect_flat_zones walks children before parents to avoid repeating container +// names and text. Password contents and partly clipped native runs stay excluded. +func collect_flat_zones(ctx context.Context, nodes []atspi.TreeNode, parents map[atspi.ObjectID]atspi.Snapshot, nodeClips map[atspi.ObjectID]atspi.Rectangle, hidden map[atspi.ObjectID]bool) ([]flatZone, error) { + var zones []flatZone + textBytes := 0 + hasContent := make(map[atspi.ObjectID]bool, len(nodes)) + // Reverse tree order allows container names to be suppressed when their + // children already supply displayed content. + for i := len(nodes) - 1; i >= 0; i-- { + if err := ctx.Err(); err != nil { + return nil, err + } + n := nodes[i] + id := n.Snapshot.ID() + parent := parents[id] + if hasContent[id] { + hasContent[parent.ID()] = true + } + if !n.Geometry.BoundsKnown || hidden[id] { + continue + } + if n.Text.Limited { + return nil, ErrLimitedReview + } + base := flatZone{object: id, parent: parent.ID(), role: n.Snapshot.Role, parentRole: parent.Role, bounds: nodeClips[id]} + before := len(zones) + if n.Snapshot.Role != "password text" { + for _, r := range n.Text.Ranges { + // Some web providers expose the same visible run on a + // container and its text child. Keep the child's source offset + // and speak the displayed run only once. + duplicate := false + if hasContent[id] { + for _, displayed := range zones { + if displayed.text != r.Text { + continue + } + if _, overlaps := displayed.bounds.Intersection(r.Bounds); !overlaps { + continue + } + for ancestor := displayed.parent; ancestor.Valid(); ancestor = parents[ancestor].ID() { + if ancestor == id { + duplicate = true + break + } + } + if duplicate { + break + } + } + } + if duplicate { + continue + } + // Native capture already clips text by character. Never keep a + // partially clipped run from another provider by changing only + // its rectangle; its text could still contain offscreen letters. + if clipped, ok := r.Bounds.Intersection(nodeClips[id]); !ok || clipped != r.Bounds { + continue + } + textBytes += len(r.Text) + if textBytes > maxFlatTextBytes || len(zones) >= maxFlatZones { + return nil, ErrLimitedReview + } + z := base + z.text, z.start, z.hasOffset, z.bounds = r.Text, r.Start, true, r.Bounds + if len(zones) > before { + previous := &zones[len(zones)-1] + if previous.start+utf8.RuneCountInString(previous.text) == r.Start && sameFlatLine(*previous, z) { + previous.text += r.Text + left := min(previous.bounds.X, r.Bounds.X) + top := min(previous.bounds.Y, r.Bounds.Y) + right := max(int64(previous.bounds.X)+int64(previous.bounds.Width), int64(r.Bounds.X)+int64(r.Bounds.Width)) + bottom := max(int64(previous.bounds.Y)+int64(previous.bounds.Height), int64(r.Bounds.Y)+int64(r.Bounds.Height)) + previous.bounds = atspi.Rectangle{X: left, Y: top, Width: int32(right - int64(left)), Height: int32(bottom - int64(top))} + continue + } + } + zones = append(zones, z) + } + } + if len(zones) == before && !hasContent[id] { + // Never derive review text from focus-speech Text, ValueText, or + // password contents. Names are synthetic and have no text offset. + base.text = n.Snapshot.Name + if base.text == "" { + switch n.Snapshot.Role { + case "entry", "text", "password text", "push button", "check box", "radio button", "toggle button", "combo box": + base.text = n.Snapshot.Role + } + } + if base.text != "" { + textBytes += len(base.text) + if textBytes > maxFlatTextBytes || len(zones) >= maxFlatZones { + return nil, ErrLimitedReview + } + zones = append(zones, base) + } + } + if len(zones) > before || hasContent[id] { + hasContent[parent.ID()] = true + } + } + return zones, nil +} + +// arrange_flat_lines keeps stable provider order for geometry ties and computes +// Unicode word spans only after all bounded content has been collected. +func arrange_flat_lines(ctx context.Context, nodes []atspi.TreeNode, zones []flatZone) ([]flatLine, error) { + if len(zones) == 0 { + return nil, ErrNoReviewContent + } + // Restore tree order before stable geometry sorts (ties retain provider + // order). Ranges within each object retain increasing logical offsets. + order := make(map[atspi.ObjectID]int, len(nodes)) + for i, n := range nodes { + order[n.Snapshot.ID()] = i + } + sort.SliceStable(zones, func(i, j int) bool { + if zones[i].bounds.Y != zones[j].bounds.Y { + return zones[i].bounds.Y < zones[j].bounds.Y + } + if order[zones[i].object] != order[zones[j].object] { + return order[zones[i].object] < order[zones[j].object] + } + return zones[i].start < zones[j].start + }) + var lines []flatLine + for _, zone := range zones { + if err := ctx.Err(); err != nil { + return nil, err + } + if len(lines) == 0 || !sameFlatLine(lines[len(lines)-1].zones[0], zone) { + lines = append(lines, flatLine{}) + } + zone.runes = []rune(zone.text) + for start := 0; start < len(zone.runes); { + if unicode.IsSpace(zone.runes[start]) { + start++ + continue + } + end := start + 1 + for end < len(zone.runes) && !unicode.IsSpace(zone.runes[end]) { + end++ + } + for end < len(zone.runes) && unicode.IsSpace(zone.runes[end]) { + end++ + } + zone.words = append(zone.words, [2]int{start, end}) + start = end + } + line := &lines[len(lines)-1] + line.zones = append(line.zones, zone) + } + for i := range lines { + sort.SliceStable(lines[i].zones, func(a, b int) bool { return lines[i].zones[a].bounds.X < lines[i].zones[b].bounds.X }) + } + return lines, nil +} diff --git a/internal/review/flat_native_test.go b/internal/review/flat_native_test.go new file mode 100644 index 0000000..91bf02d --- /dev/null +++ b/internal/review/flat_native_test.go @@ -0,0 +1,312 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestNativeFlatReview(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_FLAT") != "1" { + t.Skip("requires private Xvfb, D-Bus and GTK tree fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + var focus atspi.ObjectID + pending := []atspi.ObjectReference{root} + for len(pending) > 0 { + ref := pending[len(pending)-1] + pending = pending[:len(pending)-1] + snapshot, err := tree.Resolve(ref) + if err != nil { + t.Fatal(err) + } + if snapshot.Name == "Review text probe" { + focus = snapshot.ID() + break + } + for child, ok, err := tree.FirstChild(ref); ok && err == nil; child, ok, err = tree.NextSibling(child) { + pending = append(pending, child) + } + } + if !focus.Valid() { + t.Fatal("review text fixture missing") + } + worker, err := NewWorker(adapter, 3*time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.SyncFocus(focus) + read := func(unit FlatUnit, step int) Result { + t.Helper() + if err := worker.ReadFlat(unit, step); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + if result.Err != nil { + t.Fatal(result.Err) + } + return result + case <-ctx.Done(): + t.Fatal(ctx.Err()) + return Result{} + } + } + if result := read(Line, 0); result.FlatPosition.Text != "Café 猫" { + t.Fatalf("first native line: %q", result.FlatPosition.Text) + } + if result := read(Word, 1); strings.TrimSpace(result.FlatPosition.Text) != "猫" || result.FlatPosition.Offset != 5 { + t.Fatalf("native word: %#v", result.FlatPosition) + } + if result := read(Line, 1); result.FlatPosition.Text != "Second line" { + t.Fatalf("second native line: %q", result.FlatPosition.Text) + } + // The fixture acknowledges the mutation with its window title. Wait for + // invalidation on the adapter event stream, not an arbitrary delay. + if err := exec.CommandContext(ctx, "xdotool", "key", "t").Run(); err != nil { + t.Fatal(err) + } + for { + select { + case event := <-adapter.Events(): + if event.Source.ID() == focus && strings.HasPrefix(event.Type, "object:text-changed:") { + goto changed + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } +changed: + // Existing offset 7 now belongs to the first changed line. Review retains + // a location by source offset, not stale line index or text. + result := read(Line, 0) + if !strings.Contains(result.FlatPosition.Text, "Changed") && !strings.Contains(result.FlatPosition.Text, "New line") { + t.Fatalf("stale native content: %q", result.FlatPosition.Text) + } +} + +func TestNativeTerminalFlatReview(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_TERMINAL_FLAT") != "1" { + t.Skip("requires private Xvfb, D-Bus and VTE fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Error(err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + var focus atspi.ObjectID + pending := []atspi.ObjectReference{root} + for len(pending) > 0 { + ref := pending[len(pending)-1] + pending = pending[:len(pending)-1] + snapshot, err := tree.Resolve(ref) + if err != nil { + t.Fatal(err) + } + if snapshot.Role == "terminal" { + focus = snapshot.ID() + break + } + for child, ok, err := tree.FirstChild(ref); ok && err == nil; child, ok, err = tree.NextSibling(child) { + pending = append(pending, child) + } + } + if !focus.Valid() { + t.Fatal("review text fixture missing") + } + worker, err := NewWorker(adapter, 3*time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.SyncFocus(focus) + read := func(unit FlatUnit, step int) Result { + t.Helper() + if err := worker.ReadFlat(unit, step); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + if result.Err != nil { + t.Fatal(result.Err) + } + return result + case <-ctx.Done(): + t.Fatal(ctx.Err()) + return Result{} + } + } + + switch os.Getenv("CTHULHU_VTE_MODE") { + case "nano": + if result := read(Line, 0); !strings.HasPrefix(result.FlatPosition.Text, "Row 00000 ") { + t.Fatalf("nano initial file line: %+v", result.FlatPosition) + } + for row := 1; row <= 30; row++ { + result := read(Line, 1) + if !strings.HasPrefix(result.FlatPosition.Text, fmt.Sprintf("Row %05d ", row)) { + t.Fatalf("nano row %d: %+v", row, result.FlatPosition) + } + if row == 30 && result.FlatPosition.Offset <= 4096 { + t.Fatalf("nano probe did not exceed old text limit: %+v", result.FlatPosition) + } + } + if result := read(Word, 1); strings.TrimSpace(result.FlatPosition.Text) != "00030" { + t.Fatalf("nano word: %+v", result.FlatPosition) + } + if result := read(Character, 0); result.FlatPosition.Text != "0" { + t.Fatalf("nano character: %+v", result.FlatPosition) + } + return + case "large": + if result := read(Line, 0); !strings.HasPrefix(result.FlatPosition.Text, "Row 49 ") || result.FlatPosition.Offset <= 4096 { + t.Fatalf("large terminal caret: %+v", result.FlatPosition) + } + read(WindowStart, 0) + for row := 0; row < 50; row++ { + step := 1 + if row == 0 { + step = 0 + } + result := read(Line, step) + if !strings.HasPrefix(result.FlatPosition.Text, fmt.Sprintf("Row %02d ", row)) || !result.FlatPosition.HasOffset { + t.Fatalf("large terminal row %d: %+v", row, result.FlatPosition) + } + } + if result := read(Character, 0); result.FlatPosition.Text != "R" || result.FlatPosition.Offset <= 4096 { + t.Fatalf("large terminal character: %+v", result.FlatPosition) + } + if result := read(Word, 1); strings.TrimSpace(result.FlatPosition.Text) != "49" || result.FlatPosition.Offset <= 4096 { + t.Fatalf("large terminal word: %+v", result.FlatPosition) + } + return + case "prompt": + if result := read(Line, 0); strings.TrimSpace(result.FlatPosition.Text) != "Café 猫>" { + t.Fatalf("prompt flat review: %+v", result.FlatPosition) + } + // Generate a fresh native focus event after the adapter has started. + // ReadFocusedText requires the GLib owner's current focus reference. + window := os.Getenv("CTHULHU_TREE_WINDOW") + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", "0", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + for { + select { + case event := <-adapter.Events(): + if event.Source.ID() == focus && event.Type == "object:state-changed:focused" && event.Detail1 == 1 { + goto focused + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + focused: + if err := worker.ReadAll(ctx); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + if result.Err != nil || len(result.ReadAllLines) != 1 || strings.TrimSpace(result.ReadAllLines[0].Contents[0].Text) != "Café 猫>" { + t.Fatalf("terminal prompt Say All: %+v", result) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + return + } + + for _, tc := range []struct { + unit FlatUnit + step int + text string + mustMove bool + }{ + {WindowStart, 0, "Café 猫", false}, + {Line, 1, "", false}, + {Character, 0, "", false}, + {Line, 0, "", false}, + {Line, 1, "Second line", false}, + {Line, 1, "Third line", false}, + {Line, 1, "", false}, + {Word, 1, "", true}, + {Word, -1, "", true}, + {Line, -1, "Third line", false}, + {WindowEnd, 0, "", false}, + {Line, 1, "", false}, + {Character, 0, "", false}, + {WindowStart, 0, "Café 猫", false}, + {Word, 1, "猫", false}, + {Character, 0, "猫", false}, + } { + result := read(tc.unit, tc.step) + if strings.TrimSpace(result.FlatPosition.Text) != tc.text || !result.FlatPosition.HasOffset || (tc.mustMove && !result.Moved) { + t.Fatalf("unit=%v step=%d: %+v moved=%v, want %q with source offset", tc.unit, tc.step, result.FlatPosition, result.Moved, tc.text) + } + } +} diff --git a/internal/review/flat_pluma_native_test.go b/internal/review/flat_pluma_native_test.go new file mode 100644 index 0000000..09b563a --- /dev/null +++ b/internal/review/flat_pluma_native_test.go @@ -0,0 +1,81 @@ +package review + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Run with a private Xvfb and D-Bus session, a visible Pluma window, and a +// short document containing "Alpha beta" on its first line. +func TestNativePageTabEditorFlatReview(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PAGE_TAB_EDITOR") != "1" { + t.Skip("requires a private Pluma fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + root, err := tree.Root() + if err != nil { + t.Fatal(err) + } + var focus atspi.ObjectID + queue := []atspi.ObjectReference{root} + for len(queue) > 0 { + ref := queue[0] + queue = queue[1:] + snapshot, err := tree.Resolve(ref) + if err != nil { + t.Fatal(err) + } + if snapshot.ApplicationName == "pluma" && snapshot.Role == "text" { + geometry, err := tree.Geometry(ref) + if err != nil { + t.Fatal(err) + } + if geometry.Showing { + focus = snapshot.ID() + } + } + for child, ok, err := tree.FirstChild(ref); ok && err == nil; child, ok, err = tree.NextSibling(child) { + queue = append(queue, child) + } + } + if !focus.Valid() { + t.Fatal("visible Pluma editor not found") + } + reviewTree, err := adapter.CaptureReviewWindow(ctx, focus) + if err != nil { + t.Fatal(err) + } + cursor := &FlatCursor{} + if err := cursor.Refresh(ctx, reviewTree, focus); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + unit FlatUnit + step int + want string + }{ + {Word, 0, "Alpha"}, + {Character, 1, "l"}, + {Word, 1, "beta"}, + } { + position, _, err := cursor.Read(tc.unit, tc.step) + if err != nil || position.Object != focus || !position.HasOffset || strings.TrimSpace(position.Text) != tc.want { + t.Fatalf("unit=%v step=%d: position=%+v err=%v", tc.unit, tc.step, position, err) + } + } +} diff --git a/internal/review/flat_test.go b/internal/review/flat_test.go new file mode 100644 index 0000000..ad0073f --- /dev/null +++ b/internal/review/flat_test.go @@ -0,0 +1,546 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "strings" + "testing" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func flatNode(path, text string, x, y int32) atspi.TreeNode { + n := node(path, "") + n.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{X: x, Y: y, Width: 200, Height: 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + n.Text = atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Text: text, Start: 0, End: utf8.RuneCountInString(text), Bounds: n.Geometry.Bounds}}} + return n +} + +func flatTree(t *testing.T, nodes ...atspi.TreeNode) *atspi.AccessibleTree { + t.Helper() + root := node("/window", "Example Window") + root.Snapshot.Role = "frame" + root.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{X: -500, Y: -500, Width: 2000, Height: 2000}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + for _, n := range nodes { + root.Children = append(root.Children, n.Snapshot.ID()) + } + tree, err := atspi.NewAccessibleTree(objectID("/window"), append([]atspi.TreeNode{root}, nodes...)) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestFlatReviewSpeaksSharedHeadingTextOnce(t *testing.T) { + heading := flatNode("/heading", "Reading section", 0, 0) + heading.Snapshot.Role = "heading" + heading.Children = []atspi.ObjectID{objectID("/text")} + child := flatNode("/text", "Reading section", 0, 0) + child.Snapshot.Role = "static" + root := node("/window", "") + root.Snapshot.Role = "frame" + root.Geometry = heading.Geometry + root.Children = []atspi.ObjectID{objectID("/heading")} + tree, err := atspi.NewAccessibleTree(objectID("/window"), []atspi.TreeNode{root, heading, child}) + if err != nil { + t.Fatal(err) + } + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/heading")); err != nil { + t.Fatal(err) + } + position, _, err := cursor.Read(Line, 0) + if err != nil || position.Text != "Reading section" { + t.Fatalf("repeated heading text: position=%+v err=%v", position, err) + } +} + +func TestFlatReviewPageTabHeaderDoesNotClipPageContent(t *testing.T) { + frame := node("/window", "") + frame.Snapshot.Role = "frame" + frame.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{Width: 300, Height: 200}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + frame.Children = []atspi.ObjectID{objectID("/tab"), objectID("/hidden_tab")} + tab := node("/tab", "") + tab.Snapshot.Role = "page tab" + tab.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{Width: 100, Height: 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + tab.Children = []atspi.ObjectID{objectID("/editor")} + editor := flatNode("/editor", "Alpha beta", 0, 40) + editor.Snapshot.Role = "text" + hiddenTab := node("/hidden_tab", "") + hiddenTab.Snapshot.Role = "page tab" + hiddenTab.Geometry = atspi.Geometry{VisibilityKnown: true, Visible: true, Showing: false} + hiddenTab.Children = []atspi.ObjectID{objectID("/hidden_editor")} + hiddenEditor := flatNode("/hidden_editor", "Should stay hidden", 0, 80) + tree, err := atspi.NewAccessibleTree(objectID("/window"), []atspi.TreeNode{frame, tab, editor, hiddenTab, hiddenEditor}) + if err != nil { + t.Fatal(err) + } + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/editor")); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + unit FlatUnit + step int + text string + moved bool + }{ + {Word, 0, "Alpha ", false}, + {Character, 1, "l", true}, + {Word, 1, "beta", true}, + } { + position, moved, err := cursor.Read(tc.unit, tc.step) + if err != nil || position.Object != objectID("/editor") || position.Text != tc.text || moved != tc.moved { + t.Fatalf("unit=%v step=%d: position=%+v moved=%v err=%v", tc.unit, tc.step, position, moved, err) + } + } + if position, moved, err := cursor.Read(Line, 1); err != nil || moved || position.Object != objectID("/editor") { + t.Fatalf("hidden page entered review: position=%+v moved=%v err=%v", position, moved, err) + } +} + +func TestFlatReadingOrderAndUnicodeNavigation(t *testing.T) { + // Tree order deliberately differs from the screen order. + tree := flatTree(t, flatNode("/bottom", "next", 0, 40), flatNode("/right", "right", 250, 2), flatNode("/left", "é🙂 one", -100, 0)) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/left")); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + unit FlatUnit + step int + text string + offset int + moved bool + }{ + {Line, 0, "é🙂 one right", 0, false}, + {Character, 1, "🙂", 1, true}, + {Character, 1, " ", 2, true}, + {Word, 1, "one", 3, true}, + {Word, 1, "right", 0, true}, + {Word, 1, "next", 0, true}, + {Line, 1, "next", 0, false}, + {Character, -1, "t", 4, true}, + {Line, -1, "é🙂 one right", 4, false}, + } { + got, moved, err := cursor.Read(tc.unit, tc.step) + if err != nil || got.Text != tc.text || got.Offset != tc.offset || moved != tc.moved || !got.HasOffset { + t.Fatalf("unit=%d step=%d: %#v moved=%v err=%v", tc.unit, tc.step, got, moved, err) + } + } +} + +func TestFlatWindowStartAndEnd(t *testing.T) { + tree := flatTree(t, flatNode("/first", "first", 0, 0), flatNode("/last", "last", 0, 40)) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/last")); err != nil { + t.Fatal(err) + } + if got, moved, err := cursor.Read(WindowStart, 0); err != nil || !moved || got.Text != "first" { + t.Fatalf("start: %#v moved=%v err=%v", got, moved, err) + } + if got, moved, err := cursor.Read(WindowEnd, 0); err != nil || !moved || got.Text != "last" { + t.Fatalf("end: %#v moved=%v err=%v", got, moved, err) + } + if got, moved, err := cursor.Read(Character, 0); err != nil || moved || got.Text != "t" { + t.Fatalf("end character: %#v moved=%v err=%v", got, moved, err) + } +} + +func TestFlatVerticalWordsAndLineEnd(t *testing.T) { + bottom := flatNode("/bottom", "four", 100, 40) + firstTop := flatNode("/first", "one", 0, 0) + firstTop.Geometry.Bounds.Width = 100 + firstTop.Text.Ranges[0].Bounds.Width = 100 + secondTop := flatNode("/second", "two", 100, 0) + tree := flatTree(t, firstTop, secondTop, bottom) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/bottom")); err != nil { + t.Fatal(err) + } + if _, moved, err := cursor.Read(Character, 1); err != nil || !moved { + t.Fatalf("position character: moved=%v err=%v", moved, err) + } + got, moved, err := cursor.Read(WordAbove, 0) + if err != nil || !moved || got.Object != objectID("/second") || got.Text != "two" || got.Offset != 0 { + t.Fatalf("above: got=%+v moved=%v err=%v", got, moved, err) + } + got, moved, err = cursor.Read(WordBelow, 0) + if err != nil || !moved || got.Object != objectID("/bottom") || got.Text != "four" || got.Offset != 0 { + t.Fatalf("below: got=%+v moved=%v err=%v", got, moved, err) + } + got, moved, err = cursor.Read(LineEnd, 0) + if err != nil || !moved || got.Object != objectID("/bottom") || got.Text != "r" || got.Offset != 3 { + t.Fatalf("line end: got=%+v moved=%v err=%v", got, moved, err) + } + got, moved, err = cursor.Read(LineEnd, 0) + if err != nil || moved || got.Text != "r" || got.Offset != 3 { + t.Fatalf("line end repeat: got=%+v moved=%v err=%v", got, moved, err) + } + if _, _, err := cursor.Read(WindowStart, 0); err != nil { + t.Fatal(err) + } + got, moved, err = cursor.Read(WordAbove, 0) + if err != nil || moved || got.Text != "one" { + t.Fatalf("top boundary: got=%+v moved=%v err=%v", got, moved, err) + } +} + +func TestFlatVerticalMovementPreservesHorizontalTarget(t *testing.T) { + top := flatNode("/top", "abcd", 0, 0) + middle := flatNode("/middle", "ab", 0, 40) + bottom := flatNode("/bottom", "abcd", 0, 80) + for _, node := range []*atspi.TreeNode{&top, &middle, &bottom} { + node.Geometry.Bounds.Width = 100 + node.Text.Ranges[0].Bounds.Width = 100 + } + tree := flatTree(t, top, middle, bottom) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/top")); err != nil { + t.Fatal(err) + } + if _, moved, err := cursor.Read(Character, 1); err != nil || !moved { + t.Fatalf("position character: moved=%v err=%v", moved, err) + } + if _, moved, err := cursor.Read(WordBelow, 0); err != nil || !moved { + t.Fatalf("first below: moved=%v err=%v", moved, err) + } + // The worker refreshes the cursor before every request. The original + // horizontal target must survive that refresh for consecutive moves. + if err := cursor.Refresh(context.Background(), tree, objectID("/top")); err != nil { + t.Fatal(err) + } + if _, moved, err := cursor.Read(WordBelow, 0); err != nil || !moved { + t.Fatalf("second below: moved=%v err=%v", moved, err) + } + got, _, err := cursor.Read(Character, 0) + if err != nil || got.Object != objectID("/bottom") || got.Text != "b" || got.Offset != 1 { + t.Fatalf("preserved target: got=%+v err=%v", got, err) + } + for _, tc := range []struct { + unit FlatUnit + step int + object string + character string + }{ + {WordAbove, 0, "/middle", "a"}, + {WordAbove, 0, "/top", "b"}, + {LineEnd, 0, "/top", "d"}, + {WordBelow, 0, "/middle", "b"}, + {WordBelow, 0, "/bottom", "d"}, + {Character, -1, "/bottom", "c"}, + {WordAbove, 0, "/middle", "b"}, + {WordAbove, 0, "/top", "c"}, + } { + if _, _, err := cursor.Read(tc.unit, tc.step); err != nil { + t.Fatal(err) + } + got, _, err := cursor.Read(Character, 0) + if err != nil || got.Object != objectID(tc.object) || got.Text != tc.character { + t.Fatalf("unit %d: got=%+v err=%v", tc.unit, got, err) + } + } + if err := cursor.Refresh(context.Background(), tree, objectID("/bottom")); err != nil { + t.Fatal(err) + } + if _, _, err := cursor.Read(WordAbove, 0); err != nil { + t.Fatal(err) + } + got, _, err = cursor.Read(Character, 0) + if err != nil || got.Text != "a" { + t.Fatalf("changed focus retained old target: got=%+v err=%v", got, err) + } +} + +func TestFlatRefreshPreservesPositionAndHonorsChangedFocus(t *testing.T) { + tree := flatTree(t, flatNode("/a", "a", 0, 0), flatNode("/b", "b🙂", 0, 30)) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + cursor.Read(Line, 1) + cursor.Read(Character, 1) + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + got, _, _ := cursor.Read(Character, 0) + if got.Object != objectID("/b") || got.Text != "🙂" || got.Offset != 1 { + t.Fatalf("lost independent position: %#v", got) + } + tree.InvalidateText(objectID("/b")) + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); !errors.Is(err, atspi.ErrStaleText) { + t.Fatal(err) + } + got, _, _ = cursor.Read(Character, 0) + if got.Text != "🙂" { + t.Fatal("failed refresh mutated position") + } + tree = flatTree(t, flatNode("/a", "a", 0, 0), flatNode("/b", "b🙂", 0, 30), flatNode("/c", "c", 0, 60)) + if err := cursor.Refresh(context.Background(), tree, objectID("/c")); err != nil { + t.Fatal(err) + } + got, _, _ = cursor.Read(Character, 0) + if got.Text != "c" { + t.Fatalf("changed focus ignored: %#v", got) + } +} + +func TestFlatSyntheticNamesHiddenAndPasswordText(t *testing.T) { + password := flatNode("/password", "secret", 0, 30) + password.Snapshot.Role = "password text" + password.Snapshot.Name = "Password" + hidden := node("/hidden", "Hidden content") + button := flatNode("/button", "unused", 0, 0) + button.Text = atspi.VisibleText{} + button.Snapshot.Name = "Save" + tree := flatTree(t, button, password, hidden) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/button")); err != nil { + t.Fatal(err) + } + got, _, _ := cursor.Read(Word, 0) + if got.Text != "Save" || got.HasOffset { + t.Fatalf("synthetic name: %#v", got) + } + got, _, _ = cursor.Read(Line, 1) + if got.Text != "Password" || got.HasOffset { + t.Fatalf("password: %#v", got) + } + if len(cursor.lines) != 2 { + t.Fatalf("hidden/container content leaked: %#v", cursor.lines) + } +} + +func TestFlatClusteringDoesNotChainRowsAndKeepsRTLLogicalText(t *testing.T) { + nodes := []atspi.TreeNode{flatNode("/a", "אבג", 0, 0), flatNode("/b", "b", 250, 5), flatNode("/c", "c", 500, 10)} + lines, err := buildFlatLines(context.Background(), nodes) + if err != nil || len(lines) != 2 || len(lines[0].zones) != 2 || lines[0].zones[0].text != "אבג" { + t.Fatalf("lines=%#v err=%v", lines, err) + } +} + +func TestFlatRejectsLimitedEmptyAndInvalidRequests(t *testing.T) { + limited := flatNode("/large", "x", 0, 0) + limited.Text = atspi.VisibleText{Limited: true} + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), flatTree(t, limited), objectID("/large")); !errors.Is(err, ErrLimitedReview) { + t.Fatal(err) + } + if _, _, err := cursor.Read(Line, 0); !errors.Is(err, ErrNoReviewContent) { + t.Fatal(err) + } + if _, _, err := cursor.Read(0, 0); !errors.Is(err, ErrInvalidDirection) { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := buildFlatLines(ctx, []atspi.TreeNode{limited}); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestFlatWordWhitespaceAndCharacterBoundaries(t *testing.T) { + tree := flatTree(t, flatNode("/a", "one two", 0, 0), flatNode("/space", " ", 0, 30), flatNode("/b", "三", 0, 60)) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + got, moved, _ := cursor.Read(Character, -1) + if moved || got.Text != "o" { + t.Fatalf("start boundary: %#v %v", got, moved) + } + for _, text := range []string{"two", "三"} { + got, moved, err := cursor.Read(Word, 1) + if err != nil || !moved || strings.TrimSpace(got.Text) != text { + t.Fatalf("word: %#v %v %v", got, moved, err) + } + } + got, moved, _ = cursor.Read(Word, 1) + if moved || got.Text != "三" { + t.Fatalf("end boundary: %#v %v", got, moved) + } + got, moved, _ = cursor.Read(Word, -1) + if !moved || got.Text != "two" { + t.Fatalf("previous word: %#v %v", got, moved) + } +} + +func TestFlatClippedCharacterRunsKeepWordsAndOffsetGaps(t *testing.T) { + n := flatNode("/a", "x", 0, 0) + n.Text.Ranges = []atspi.TextRange{ + {Start: 2, End: 3, Text: "猫", Bounds: atspi.Rectangle{X: 0, Y: 0, Width: 12, Height: 20}}, + {Start: 3, End: 4, Text: "a", Bounds: atspi.Rectangle{X: 12, Y: 0, Width: 10, Height: 20}}, + {Start: 7, End: 8, Text: "z", Bounds: atspi.Rectangle{X: 80, Y: 0, Width: 10, Height: 20}}, + } + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil { + t.Fatal(err) + } + got, _, _ := cursor.Read(Line, 0) + if got.Text != "猫a z" || got.Offset != 2 { + t.Fatalf("fragmented line: %#v", got) + } + got, _, _ = cursor.Read(Word, 0) + if got.Text != "猫a" { + t.Fatalf("fragmented word: %#v", got) + } + got, _, _ = cursor.Read(Word, 1) + if got.Text != "z" || got.Offset != 7 { + t.Fatalf("offset gap lost: %#v", got) + } +} + +func TestFlatClipsSyntheticNamesAndSuppressesHiddenDescendants(t *testing.T) { + parent := flatNode("/parent", "unused", 0, 0) + parent.Text = atspi.VisibleText{} + parent.Snapshot.Name = "Container" + child := flatNode("/child", "unused", 150, 0) + child.Text = atspi.VisibleText{} + child.Snapshot.Name = "Button" + parent.Children = []atspi.ObjectID{child.Snapshot.ID()} + lines, err := buildFlatLines(context.Background(), []atspi.TreeNode{parent, child}) + if err != nil || len(lines) != 1 || len(lines[0].zones) != 1 || lines[0].zones[0].bounds.Width != 50 { + t.Fatalf("clipping: %#v %v", lines, err) + } + parent.Geometry = atspi.Geometry{VisibilityKnown: true} + if _, err := buildFlatLines(context.Background(), []atspi.TreeNode{parent, child}); !errors.Is(err, ErrNoReviewContent) { + t.Fatalf("hidden descendant: %v", err) + } +} + +func TestFlatCaretAndSyntheticCharacterPosition(t *testing.T) { + n := flatNode("/a", "ab🙂", 0, 0) + n.Text.CaretKnown, n.Text.CaretOffset = true, 2 + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil { + t.Fatal(err) + } + got, _, _ := cursor.Read(Character, 0) + if got.Text != "🙂" || got.Offset != 2 { + t.Fatalf("caret: %#v", got) + } + cursor = &FlatCursor{} + n.Text.CaretOffset = 3 + if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil { + t.Fatal(err) + } + got, _, _ = cursor.Read(Character, 0) + if got.Text != "" || got.Offset != 3 { + t.Fatalf("end caret: %#v", got) + } + got, moved, _ := cursor.Read(Character, -1) + if !moved || got.Text != "🙂" { + t.Fatalf("before end caret: %#v", got) + } + n.Text = atspi.VisibleText{} + n.Snapshot.Name = "Save" + tree := flatTree(t, n) + cursor = &FlatCursor{} + for _, want := range []string{"a", "v", "e"} { + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + got, moved, err := cursor.Read(Character, 1) + if err != nil || !moved || got.Text != want { + t.Fatalf("synthetic position: %#v %v", got, err) + } + } +} + +func TestFlatModelBoundsSyntheticText(t *testing.T) { + var nodes []atspi.TreeNode + for i := 0; i < 300; i++ { + n := flatNode("/a", "x", 0, int32(i*20)) + n.Text = atspi.VisibleText{} + n.Snapshot.Name = strings.Repeat("a", 4096) + nodes = append(nodes, n) + } + if _, err := buildFlatLines(context.Background(), nodes); !errors.Is(err, ErrLimitedReview) { + t.Fatal(err) + } +} + +func TestFlatDoesNotRestoreReusedObjectIdentity(t *testing.T) { + tree := flatTree(t, flatNode("/a", "Focus", 0, 0), flatNode("/b", "Original", 0, 30)) + cursor := &FlatCursor{} + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + cursor.Read(Line, 1) + tree.Invalidate(objectID("/b")) + fresh := flatTree(t, flatNode("/a", "Focus", 0, 0), flatNode("/b", "Replacement", 0, 30)) + snapshot, err := fresh.ReviewWindow(context.Background(), objectID("/a")) + if err != nil { + t.Fatal(err) + } + if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil { + t.Fatal(err) + } + if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + got, _, _ := cursor.Read(Line, 0) + if got.Text != "Focus" { + t.Fatalf("reused identity inherited position: %#v", got) + } +} + +func TestFlatBlankRowsAcrossReviewUnits(t *testing.T) { + n := flatNode("/terminal", "a", 0, 0) + n.Geometry.Bounds.Height = 80 + n.Text.Ranges = []atspi.TextRange{ + {Start: 0, End: 1, Text: "a", Bounds: atspi.Rectangle{Width: 20, Height: 20}}, + {Start: 2, End: 3, Text: "\n", Bounds: atspi.Rectangle{Y: 20, Width: 20, Height: 20}}, + {Start: 3, End: 4, Text: "b", Bounds: atspi.Rectangle{Y: 40, Width: 20, Height: 20}}, + {Start: 5, End: 6, Text: "\n", Bounds: atspi.Rectangle{Y: 60, Width: 20, Height: 20}}, + } + tree := flatTree(t, n) + cursor := &FlatCursor{} + for _, tc := range []struct { + unit FlatUnit + step int + text string + offset int + moved bool + }{ + {Line, 1, "\n", 2, true}, + {Character, 0, "\n", 2, false}, + {Line, 0, "\n", 2, false}, + {Line, 1, "b", 3, true}, + {Word, -1, "\n", 2, true}, + {Word, -1, "a", 0, true}, + {Word, 1, "\n", 2, true}, + {Word, 1, "b", 3, true}, + {WindowEnd, 0, "\n", 5, true}, + {Line, 1, "\n", 5, false}, + {WindowStart, 0, "a", 0, true}, + {Character, 1, "\n", 2, true}, + {Character, 1, "b", 3, true}, + } { + if err := cursor.Refresh(context.Background(), tree, n.Snapshot.ID()); err != nil { + t.Fatal(err) + } + got, moved, err := cursor.Read(tc.unit, tc.step) + if err != nil || got.Text != tc.text || got.Offset != tc.offset || moved != tc.moved { + t.Fatalf("%+v: got=%+v moved=%v err=%v", tc, got, moved, err) + } + } +} diff --git a/internal/review/flat_worker.go b/internal/review/flat_worker.go new file mode 100644 index 0000000..818b729 --- /dev/null +++ b/internal/review/flat_worker.go @@ -0,0 +1,132 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// SetFlatOrigin starts a new flat-review context at the browse caret without +// changing native focus. Once review begins, its cursor moves independently. +func (worker *Worker) SetFlatOrigin(object atspi.ObjectID, offset int) { + worker.stateMutex.Lock() + worker.flatOrigin, worker.flatOffset = object, offset + worker.stateMutex.Unlock() +} + +func (worker *Worker) flatStart() (atspi.ObjectID, int) { + worker.stateMutex.Lock() + defer worker.stateMutex.Unlock() + return worker.flatOrigin, worker.flatOffset +} + +// FlatSpeech controls the speech of a review result without moving its cursor. +type FlatSpeech uint8 + +const ( + FlatSpeak FlatSpeech = iota + FlatSpell + FlatPhonetic +) + +// ReadFlat queues sequential review on the same bounded FIFO as object review. +// Capacity is 16; overflow rejects the newest request and is reported to the +// caller. Results retain FIFO order in a 16-result queue; a stalled consumer +// blocks only this worker. Cancellation discards pending work and joins it. +func (worker *Worker) ReadFlat(unit FlatUnit, step int) error { + return worker.ReadFlatWithSpeech(unit, step, FlatSpeak) +} + +// ReadFlatWithSpeech retains presentation policy with its FIFO request, so a +// later press cannot change the speech of an earlier asynchronous result. +func (worker *Worker) ReadFlatWithSpeech(unit FlatUnit, step int, style FlatSpeech) error { + if unit < Line || unit > LineEnd || step < -1 || step > 1 || (unit > Character && step != 0) { + return ErrInvalidDirection + } + if style > FlatPhonetic || (style != FlatSpeak && (step != 0 || (unit != Word && unit != Character))) { + return ErrInvalidDirection + } + select { + case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), unit: unit, step: step, flatSpeech: style}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) readFlat(ctx context.Context, cursor *FlatCursor, tree *atspi.AccessibleTree, unit FlatUnit, step int) (Result, *atspi.AccessibleTree) { + focus := worker.focus() + result := Result{FlatUnit: unit, FlatStep: step, FlatFocus: focus} + if !focus.Valid() { + result.Err = ErrNoCurrent + return result, tree + } + fresh := false + if policy, ok := worker.capturer.(FreshReviewWindowCapturer); ok { + fresh = policy.FreshReviewWindow(focus) + } + var err error + if !fresh { + err = cursor.Refresh(ctx, tree, focus) + } + if fresh || errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrIncompleteTree) || errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) { + result.CaptureReason = err + timeout := worker.flatCaptureTimeout + if fresh { + timeout = min(timeout, 500*time.Millisecond) + } + captureContext, cancel := context.WithTimeout(ctx, timeout) + captureStarted := time.Now() + captured, captureErr := worker.capturer.CaptureReviewWindow(captureContext, focus) + result.CaptureDuration = time.Since(captureStarted) + cancel() + if captureErr != nil { + result.Err = captureErr + return result, tree + } + if captured == nil { + result.Err = errors.New("capture flat review tree: nil tree") + return result, tree + } + tree = captured + // Capture can outlive a focus transition. Use the newest identity and + // refresh only once: repeated invalidation cannot cause a retry loop. + result.FlatFocus = worker.focus() + err = cursor.Refresh(ctx, tree, result.FlatFocus) + } + if err != nil { + result.Err = err + return result, tree + } + if cursor.focus == focus && !cursor.started { + if origin, offset := worker.flatStart(); origin.Valid() { + if !cursor.seek(origin, offset, true) { + if !cursor.seek(origin, 0, false) { + cursor.seekDescendant(origin) + } + } + } + cursor.started = true + } + result.FlatPosition, result.Moved, result.Err = cursor.Read(unit, step) + return result, tree +} diff --git a/internal/review/flat_worker_test.go b/internal/review/flat_worker_test.go new file mode 100644 index 0000000..23c7485 --- /dev/null +++ b/internal/review/flat_worker_test.go @@ -0,0 +1,265 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type freshFlatCapturer struct{ *fakeCapturer } + +func (*freshFlatCapturer) FreshReviewWindow(atspi.ObjectID) bool { return true } +func TestFlatWorkerFreshProviderPreservesCursor(t *testing.T) { + capturer := &freshFlatCapturer{&fakeCapturer{calls: make(chan captureCall, 1)}} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/a")) + tree := flatTree(t, flatNode("/a", "alpha beta", 0, 0)) + for i, step := range []int{0, 1, 0} { + worker.ReadFlat(Word, step) + var call captureCall + select { + case call = <-capturer.calls: + case <-time.After(time.Second): + t.Fatal("command reused cached text") + } + if i == 2 { + fresh := flatTree(t, flatNode("/a", "omega zeta", 0, 0)) + snapshot, _ := fresh.ReviewWindow(context.Background(), objectID("/a")) + if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil { + t.Fatal(err) + } + } + call.result <- captureResult{tree: tree} + got := waitResult(t, worker) + want := []string{"alpha ", "beta", "zeta"}[i] + if got.Err != nil || got.FlatPosition.Text != want { + t.Fatalf("cursor: %+v want=%s", got, want) + } + } +} +func TestFlatWorkerFreshProviderFailureDoesNotSpeakCachedText(t *testing.T) { + capturer := &freshFlatCapturer{&fakeCapturer{calls: make(chan captureCall, 1)}} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/a")) + worker.ReadFlat(Line, 0) + call := <-capturer.calls + call.result <- captureResult{tree: flatTree(t, flatNode("/a", "secret old text", 0, 0))} + waitResult(t, worker) + worker.ReadFlat(Line, 0) + select { + case call = <-capturer.calls: + case <-time.After(time.Second): + t.Fatal("failed freshness policy reused cached text") + } + call.result <- captureResult{err: context.DeadlineExceeded} + result := waitResult(t, worker) + if !errors.Is(result.Err, context.DeadlineExceeded) || result.FlatPosition.Text != "" { + t.Fatalf("cached output: %+v", result) + } +} + +func TestFlatWorkerRecapturesOnceAndHonorsFocusDuringCapture(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/a")) + if err := worker.ReadFlat(Line, 0); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + if call.reviewFocus != objectID("/a") { + t.Fatalf("review capture focus = %#v", call.reviewFocus) + } + worker.SyncFocus(objectID("/b")) + tree := flatTree(t, flatNode("/a", "First", 0, 0), flatNode("/b", "Second", 0, 30)) + call.result <- captureResult{tree: tree} + if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Second" { + t.Fatalf("result=%#v", result) + } + worker.ReadFlat(Line, -1) + if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "First" { + t.Fatalf("result=%#v", result) + } + tree.InvalidateText(objectID("/a")) + worker.ReadFlat(Line, 0) + call = <-capturer.calls + // A provider that remains stale after one recapture must fail, not loop. + call.result <- captureResult{tree: tree} + if result := waitResult(t, worker); !errors.Is(result.Err, atspi.ErrStaleText) || result.FlatPosition.Text != "" { + t.Fatalf("result=%#v", result) + } + worker.ReadFlat(Line, 0) + call = <-capturer.calls + fresh := flatTree(t, flatNode("/a", "Refreshed", 0, 0), flatNode("/b", "Second", 0, 30)) + snapshot, err := fresh.ReviewWindow(context.Background(), objectID("/a")) + if err != nil { + t.Fatal(err) + } + if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil { + t.Fatal(err) + } + call.result <- captureResult{tree: tree} + if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Refreshed" { + t.Fatalf("independent position lost: %#v", result) + } +} + +func TestFlatWorkerPreservesQueuedSpeechStyles(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/a")) + for _, style := range []FlatSpeech{FlatSpeak, FlatSpell, FlatPhonetic} { + if err := worker.ReadFlatWithSpeech(Word, 0, style); err != nil { + t.Fatal(err) + } + } + call := <-capturer.calls + call.result <- captureResult{tree: flatTree(t, flatNode("/a", "book", 0, 0))} + for _, style := range []FlatSpeech{FlatSpeak, FlatSpell, FlatPhonetic} { + result := waitResult(t, worker) + if result.Err != nil || result.FlatSpeech != style || result.FlatPosition.Text != "book" { + t.Fatalf("queued style=%d result=%#v", style, result) + } + } +} + +func TestFlatWorkerStartsAtBrowsePositionThenMovesIndependently(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/window")) + worker.SetFlatOrigin(objectID("/b"), 0) + if err := worker.ReadFlat(Line, 0); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + call.result <- captureResult{tree: flatTree(t, flatNode("/a", "First", 0, 0), flatNode("/b", "Second", 0, 30))} + if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Second" { + t.Fatalf("initial browse position: %+v", result) + } + worker.SetFlatOrigin(objectID("/a"), 0) + if err := worker.ReadFlat(Line, 0); err != nil { + t.Fatal(err) + } + if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Second" { + t.Fatalf("flat cursor followed later browse movement: %+v", result) + } +} + +func TestFlatWorkerCaptureCancellationAndQueueBounds(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + worker.SyncFocus(objectID("/a")) + worker.ReadFlat(Line, 0) + call := <-capturer.calls + for i := 0; i < requestCapacity; i++ { + if err := worker.ReadFlat(Word, 1); err != nil { + t.Fatal(err) + } + } + if err := worker.ReadFlat(Character, 1); !errors.Is(err, ErrRequestQueueFull) { + t.Fatal(err) + } + stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second) + defer stopCancel() + if err := worker.Stop(stopCtx); err != nil { + t.Fatal(err) + } + if !errors.Is(call.ctx.Err(), context.Canceled) { + t.Fatal("capture was not canceled") + } +} + +func TestFlatWorkerUsesSeparateCaptureDeadline(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.SetFlatCaptureTimeout(10 * time.Second); err != nil { + t.Fatal(err) + } + if err := worker.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + if err := worker.SetFlatCaptureTimeout(time.Second); err == nil { + t.Fatal("changed flat review deadline while worker was running") + } + worker.SyncFocus(objectID("/a")) + if err := worker.ReadFlat(Line, 0); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + deadline, ok := call.ctx.Deadline() + if !ok || time.Until(deadline) < 8*time.Second { + t.Fatalf("flat review capture deadline = %v, present = %t", deadline, ok) + } + call.result <- captureResult{tree: flatTree(t, flatNode("/a", "Visible", 0, 0))} + if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Visible" { + t.Fatalf("flat review = %+v", result) + } +} + +func TestFlatWorkerAcceptsVerticalAndLineEndUnits(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/bottom")) + for index, tc := range []struct { + unit FlatUnit + text string + }{ + {WordAbove, "first"}, + {WordBelow, "last"}, + {LineEnd, "t"}, + } { + if err := worker.ReadFlat(tc.unit, 0); err != nil { + t.Fatalf("unit %d: %v", tc.unit, err) + } + if index == 0 { + select { + case call := <-capturer.calls: + call.result <- captureResult{tree: flatTree(t, flatNode("/top", "first", 0, 0), flatNode("/bottom", "last", 0, 40))} + case <-time.After(time.Second): + t.Fatal("flat review capture did not start") + } + } + if result := waitResult(t, worker); result.Err != nil || result.FlatUnit != tc.unit || result.FlatPosition.Text != tc.text || !result.Moved { + t.Fatalf("unit %d: result=%+v", tc.unit, result) + } + } + if err := worker.ReadFlat(LineEnd, 1); !errors.Is(err, ErrInvalidDirection) { + t.Fatalf("line end step: %v", err) + } +} diff --git a/internal/review/interest.go b/internal/review/interest.go new file mode 100644 index 0000000..dbcf025 --- /dev/null +++ b/internal/review/interest.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Interest belongs to the review worker. Zone bounds are provider coordinates; +// callers must resolve fresh source/window geometry before screen capture. +// A flat zone must never silently fall back to the larger object's bounds. +type Interest struct { + Offset int // -1 means use the native caret; virtual browser carets supply an offset. + Object atspi.ObjectID + Zone atspi.Rectangle + HasZone bool +} + +type interestMode uint8 + +const ( + implicitInterest interestMode = iota + objectInterest + flatInterest +) + +type interestReply struct { + interest Interest + err error +} +type interestRequest struct { + ctx context.Context + result chan interestReply +} + +// ObjectOfInterest is ordered with navigation, and never captures a tree. Call +// it on a worker. Explicit review wins over a virtual browser caret, then focus. +// Failed/stale explicit review returns an error instead of capturing focus. +func (worker *Worker) ObjectOfInterest(ctx context.Context) (Interest, error) { + if err := ctx.Err(); err != nil { + return Interest{}, err + } + worker.stateMutex.Lock() + done, started := worker.done, worker.started + worker.stateMutex.Unlock() + if !started { + return Interest{}, errors.New("review worker is not running") + } + select { + case <-done: + return Interest{}, ErrNoCurrent + default: + } + query := &interestRequest{ctx: ctx, result: make(chan interestReply, 1)} + select { + case worker.requests <- request{interest: query}: + case <-ctx.Done(): + return Interest{}, ctx.Err() + case <-done: + return Interest{}, ErrNoCurrent + } + select { + case reply := <-query.result: + return reply.interest, reply.err + case <-ctx.Done(): + return Interest{}, ctx.Err() + case <-done: + return Interest{}, ErrNoCurrent + } +} + +func resolve_interest(ctx context.Context, focus atspi.ObjectID, explicit interestMode, cursor *Cursor, flat *FlatCursor, flatTree *atspi.AccessibleTree, document *DocumentCursor) (Interest, error) { + if err := ctx.Err(); err != nil { + return Interest{}, err + } + if !focus.Valid() { + return Interest{}, ErrNoCurrent + } + switch explicit { + case objectInterest: + if cursor == nil { + return Interest{}, ErrNoCurrent + } + position, err := cursor.Current() + return Interest{Object: position.Current.ID(), Offset: -1}, err + case flatInterest: + if err := flat.Refresh(ctx, flatTree, focus); err != nil { + return Interest{}, err + } + position := flat.position(Character) + if !position.Bounds.Valid() { + return Interest{}, atspi.ErrStaleGeometry + } + return Interest{Object: position.Object, Offset: -1, Zone: position.Bounds, HasZone: true}, nil + } + if document != nil { + position, err := document.SyncFocus(ctx, focus) + if err == nil && position.Origin != NativeNavigation { + return Interest{Object: position.Object.ID(), Offset: position.Offset}, nil + } + if err != nil && !errors.Is(err, atspi.ErrNotInDocument) && !errors.Is(err, atspi.ErrObjectNotFound) { + return Interest{}, err + } + } + return Interest{Object: focus, Offset: -1}, nil +} diff --git a/internal/review/interest_test.go b/internal/review/interest_test.go new file mode 100644 index 0000000..559ff0f --- /dev/null +++ b/internal/review/interest_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestObjectInterestIsOrderedAndPreservesExplicitReview(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/first")) + ctx, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + got, err := worker.ObjectOfInterest(ctx) + if err != nil || got.Object != objectID("/first") { + t.Fatalf("focus interest: %+v %v", got, err) + } + worker.Move(NextSibling) + call := <-capturer.calls + replies := make(chan interestReply, 1) + go func() { interest, err := worker.ObjectOfInterest(ctx); replies <- interestReply{interest, err} }() + tree := newTree(t) + call.result <- captureResult{tree: tree} + if result := waitResult(t, worker); result.Err != nil { + t.Fatal(result.Err) + } + reply := <-replies + if reply.err != nil || reply.interest.Object != objectID("/second") { + t.Fatalf("explicit review snapped to focus: %+v", reply) + } + worker.SyncFocus(objectID("/grandchild")) + worker.SyncFocus(objectID("/first")) + got, err = worker.ObjectOfInterest(ctx) + if err != nil || got.Object != objectID("/first") { + t.Fatalf("away/back retained explicit review: %+v %v", got, err) + } + worker.SyncFocus(objectID("/grandchild")) + got, err = worker.ObjectOfInterest(ctx) + if err != nil || got.Object != objectID("/grandchild") { + t.Fatalf("changed focus: %+v %v", got, err) + } + worker.Move(Parent) + if result := waitResult(t, worker); result.Err != nil { + t.Fatal(result.Err) + } + tree.Invalidate(objectID("/second")) + if _, err = worker.ObjectOfInterest(ctx); err == nil { + t.Fatal("stale explicit interest fell back to focus") + } + worker.ClearFocus() + if _, err = worker.ObjectOfInterest(ctx); !errors.Is(err, ErrNoCurrent) { + t.Fatalf("clear focus: %v", err) + } +} + +func TestFlatInterestUsesZoneAndDoesNotRecapture(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + defer worker.Stop(context.Background()) + worker.SyncFocus(objectID("/b")) + worker.ReadFlat(Line, 0) + call := <-capturer.calls + tree := flatTree(t, flatNode("/a", "First", 0, 0), flatNode("/b", "Second", 0, 30)) + call.result <- captureResult{tree: tree} + waitResult(t, worker) + worker.ReadFlat(Line, -1) + position := waitResult(t, worker).FlatPosition + ctx, cancelQuery := context.WithTimeout(context.Background(), time.Second) + defer cancelQuery() + got, err := worker.ObjectOfInterest(ctx) + if err != nil || got.Object != objectID("/a") || !got.HasZone || got.Zone != position.Bounds { + t.Fatalf("zone: %+v %v", got, err) + } + tree.InvalidateGeometry(objectID("/a")) + if _, err = worker.ObjectOfInterest(ctx); !errors.Is(err, atspi.ErrStaleGeometry) { + t.Fatalf("stale zone: %v", err) + } + select { + case <-capturer.calls: + t.Fatal("interest recaptured a tree") + default: + } +} + +func TestInterestPrefersVirtualCaretThenExplicitReview(t *testing.T) { + ctx := context.Background() + reader := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} + document := newCaretTextCursor(t, reader) + if _, err := document.SyncFocus(ctx, webID("/text")); err != nil { + t.Fatal(err) + } + if _, err := document.MoveCaret(ctx, reader, CaretNextLine); err != nil { + t.Fatal(err) + } + got, err := resolve_interest(ctx, webID("/text"), 0, nil, nil, nil, document) + if err != nil || got.Object != webID("/next") { + t.Fatalf("virtual caret: %+v %v", got, err) + } + cursor, _ := NewCursor(newTree(t)) + cursor.SyncFocus(objectID("/first")) + cursor.Move(NextSibling) + got, err = resolve_interest(ctx, webID("/text"), 1, cursor, nil, nil, document) + if err != nil || got.Object != objectID("/second") { + t.Fatalf("explicit precedence: %+v %v", got, err) + } +} + +func TestInterestCancelledAndStopped(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + ctx, stop := context.WithCancel(context.Background()) + stop() + if _, err := worker.ObjectOfInterest(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel: %v", err) + } + cancel() + worker.Stop(context.Background()) + if _, err := worker.ObjectOfInterest(context.Background()); !errors.Is(err, ErrNoCurrent) { + t.Fatalf("stop: %v", err) + } +} diff --git a/internal/review/object_worker.go b/internal/review/object_worker.go new file mode 100644 index 0000000..5fe1c72 --- /dev/null +++ b/internal/review/object_worker.go @@ -0,0 +1,112 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "fmt" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type moveResult struct { + Result + cursor *Cursor +} + +func (worker *Worker) move(ctx context.Context, cursor *Cursor, direction Direction) moveResult { + var err error + cursor, err = worker.syncLatestFocus(ctx, cursor, true) + if err != nil { + return moveResult{Result: Result{Direction: direction, Err: err}, cursor: cursor} + } + if cursor == nil { + return moveResult{Result: Result{Direction: direction, Err: ErrNoCurrent}} + } + position, moved, err := cursor.Move(direction) + if err == nil { + return moveResult{Result: Result{Direction: direction, Position: position, Moved: moved}, cursor: cursor} + } + if !errors.Is(err, atspi.ErrStaleObject) && !errors.Is(err, atspi.ErrIncompleteTree) { + return moveResult{Result: Result{Direction: direction, Err: err}, cursor: cursor} + } + + // An incomplete child list needs recapture without abandoning an otherwise + // live independent review position. Stale objects still recover from focus. + var retained *Cursor + if errors.Is(err, atspi.ErrIncompleteTree) { + retained = cursor + } + recovered, recoveryErr := worker.captureCursor(ctx, retained) + if recoveryErr != nil { + return moveResult{Result: Result{Direction: direction, Err: recoveryErr}, cursor: retained} + } + if recovered == nil { + return moveResult{Result: Result{Direction: direction, Err: err}} + } + position, moved, err = recovered.Move(direction) + return moveResult{Result: Result{Direction: direction, Position: position, Moved: moved, Err: err}, cursor: recovered} +} + +func (worker *Worker) syncLatestFocus(ctx context.Context, cursor *Cursor, captureIfMissing bool) (*Cursor, error) { + focus := worker.focus() + if !focus.Valid() { + return cursor, nil + } + if cursor != nil { + _, currentErr := cursor.Current() + _, syncErr := cursor.SyncFocus(focus) + if syncErr == nil && !errors.Is(currentErr, atspi.ErrStaleObject) { + return cursor, nil + } + if syncErr != nil && !errors.Is(syncErr, atspi.ErrObjectNotFound) && !errors.Is(syncErr, atspi.ErrStaleObject) { + return cursor, syncErr + } + } + if !captureIfMissing && cursor == nil { + return nil, nil + } + + return worker.captureCursor(ctx, nil) +} + +func (worker *Worker) captureCursor(ctx context.Context, cursor *Cursor) (*Cursor, error) { + captureContext, cancelCapture := context.WithTimeout(ctx, worker.captureTimeout) + tree, err := worker.capturer.CaptureDesktop(captureContext) + cancelCapture() + if err != nil { + return nil, fmt.Errorf("capture object review tree: %w", err) + } + if tree == nil { + return nil, errors.New("capture object review tree: nil tree") + } + if cursor == nil || cursor.tree != tree { + cursor, err = NewCursor(tree) + if err != nil { + return nil, err + } + } + // Focus can change while capture is underway. Always synchronize to the + // newest identity after the capture returns. + focus := worker.focus() + if _, err := cursor.SyncFocus(focus); err != nil { + return nil, err + } + return cursor, nil +} diff --git a/internal/review/read_all.go b/internal/review/read_all.go new file mode 100644 index 0000000..a0ac032 --- /dev/null +++ b/internal/review/read_all.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Assemble from a private cursor: collecting speech must not move the user's +// browse position to the end before any of it has actually been spoken. +func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Result { + result := Result{DocumentFocus: worker.focus()} + if !result.DocumentFocus.Valid() { + result.Err = ErrNoCurrent + return result + } + if cursor != nil { + cursor = &DocumentCursor{tree: cursor.tree, active: cursor.active, places: append([]documentPlace(nil), cursor.places...)} + _, result.Err = cursor.syncActionFocus(ctx, result.DocumentFocus) + } else { + result.Err = ErrNoCurrent + } + if documentNeedsCapture(result.Err) { + cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus) + } + if documentNeedsCapture(result.Err) { + cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus) + } + if errors.Is(result.Err, atspi.ErrNotInDocument) { + if reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface { + ReadFocusedText(context.Context, atspi.ObjectID) (atspi.DocumentContent, error) + }); ok { + deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + var content atspi.DocumentContent + content, result.Err = reader.ReadFocusedText(deadline, result.DocumentFocus) + if result.Err == nil { + result.ReadAllLines = focusedReadAllLines(content) + epoch := worker.documentEpoch.Load() + result.ReadAllProgress = func(index int) { + if index < 0 || index >= len(result.ReadAllLines) || content.Text == "" { + return + } + part := result.ReadAllLines[index].Contents[0] + part.Snapshot.Text = part.Text + position := DocumentPosition{Offset: part.Start, Snapshot: part.Snapshot} + select { + case worker.requests <- request{documentEpoch: epoch, readPosition: &position, readFocus: result.DocumentFocus}: + default: + } + } + } + } + return result + } + if result.Err != nil { + return result + } + reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(DocumentCaretReader) + if !ok { + result.Err = errors.New("read all: document text reader unavailable") + return result + } + command := CaretReadLine + var previous DocumentPosition + var positions []DocumentPosition + epoch := worker.documentEpoch.Load() + result.ReadAllProgress = func(index int) { + if index < 0 || index >= len(positions) { + return + } + position := positions[index] + select { + case worker.requests <- request{documentEpoch: epoch, readPosition: &position, readTree: cursor.tree, readFocus: result.DocumentFocus}: + default: + } + } + bytes := 0 + var movement DocumentCaretMove + var err error + for { + if err := ctx.Err(); err != nil { + result.Err = err + return result + } + movement, cursor, err = worker.readAllMove(ctx, cursor, reader, command, result.DocumentFocus) + if err != nil { + result.Err = err + return result + } + if !movement.Moved { + return result + } + if command == CaretNextLine && movement.Position.Object == previous.Object && movement.Position.Offset <= previous.Offset { + result.Err = errors.New("read all: text provider did not advance") + return result + } + previous = movement.Position + movement.Position.Snapshot.Misspelled = movement.Misspelled + line := atspi.DocumentLine{Document: movement.Position.Document, Contents: []atspi.DocumentContent{{Snapshot: movement.Position.Snapshot, Text: movement.Text, RawText: movement.RawText}}} + owner, err := cursor.tree.DocumentContext(movement.Position.Object) + if err != nil { + if !documentNeedsCapture(err) { + result.Err = err + return result + } + refreshed, refreshErr := worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus) + if refreshErr != nil { + result.Err = refreshErr + return result + } + cursor = refreshed + owner = atspi.DocumentContext{} + } else { + line.Context = owner.Ancestors + roles := map[string]bool{movement.Position.Snapshot.Role: true} + for _, ancestor := range owner.Ancestors { + if (ancestor.Role == "link" || ancestor.Role == "heading") && !roles[ancestor.Role] { + roles[ancestor.Role] = true + ancestor.Name = "" + line.Contents = append(line.Contents, atspi.DocumentContent{Snapshot: ancestor}) + } + } + } + bytes += len(movement.Text) + len(movement.Position.Snapshot.Name) + if bytes > 1<<20 || len(result.ReadAllLines) >= 32768 { + result.Err = errors.New("read all: document exceeds speech capture limit") + return result + } + result.ReadAllLines = append(result.ReadAllLines, line) + positions = append(positions, movement.Position) + command = CaretNextLine + } +} + +func (worker *Worker) readAllMove(ctx context.Context, cursor *DocumentCursor, reader DocumentCaretReader, command DocumentCaretCommand, focus atspi.ObjectID) (DocumentCaretMove, *DocumentCursor, error) { + deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout) + movement, err := cursor.MoveCaret(deadline, reader, command) + cancel() + if err == nil || !documentNeedsCapture(err) { + return movement, cursor, err + } + cursor, refreshErr := worker.refreshDocumentCursor(ctx, cursor, focus) + if refreshErr != nil { + return movement, cursor, refreshErr + } + deadline, cancel = context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + movement, err = cursor.MoveCaret(deadline, reader, command) + return movement, cursor, err +} + +func (worker *Worker) followReadAll(ctx context.Context, cursor *DocumentCursor, request request) (*DocumentCursor, bool) { + if !request.readPosition.Document.Valid() { + if setter, ok := worker.nativeCapturerFor(request.readFocus).(interface { + SetFocusedTextCaret(context.Context, atspi.ObjectID, int) error + }); ok { + deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout) + defer cancel() + return cursor, setter.SetFocusedTextCaret(deadline, request.readFocus, request.readPosition.Offset) == nil + } + return cursor, false + } + if cursor == nil || cursor.tree != request.readTree { + cursor, _ = NewDocumentCursor(request.readTree) + } + if cursor == nil { + return nil, false + } + current, err := cursor.SyncFocus(ctx, request.readFocus) + position := request.readPosition + if err != nil || current.Document.ID() != position.Document.ID() { + return cursor, false + } + // A page can be recaptured while Say All is being assembled. Earlier + // lines retain references from the old tree, so bind the spoken object's + // identity to the tree that will receive the progress update. + object, err := cursor.tree.Reference(position.Object.ID()) + if err != nil { + return cursor, false + } + owner, err := cursor.tree.DocumentContext(object) + if err != nil || owner.TopLevel != current.Document { + return cursor, false + } + snapshot, err := cursor.tree.Resolve(object) + if err != nil || position.Offset < 0 || position.Offset > snapshot.CharacterCount { + return cursor, false + } + position.Document, position.Object, position.Snapshot = current.Document, object, snapshot + place := &cursor.places[len(cursor.places)-1] + place.object, place.offset, place.origin = object, position.Offset, CaretNavigation + return cursor, true +} + +func focusedReadAllLines(content atspi.DocumentContent) []atspi.DocumentLine { + if content.Text == "" { + return []atspi.DocumentLine{{Contents: []atspi.DocumentContent{content}}} + } + var lines []atspi.DocumentLine + offset := content.Start + for _, value := range strings.SplitAfter(content.Text, "\n") { + part := content + part.Start, part.End, part.Text = offset, offset+utf8.RuneCountInString(value), value + offset = part.End + if strings.TrimSpace(value) != "" { + lines = append(lines, atspi.DocumentLine{Contents: []atspi.DocumentContent{part}}) + } + } + return lines +} diff --git a/internal/review/read_all_test.go b/internal/review/read_all_test.go new file mode 100644 index 0000000..24e9b92 --- /dev/null +++ b/internal/review/read_all_test.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type readAllCapturer struct { + *fakeCapturer + caretTextReader + tree *atspi.AccessibleTree + nativePlacements int +} + +func (capturer *readAllCapturer) CaptureDocument(ctx context.Context, _ atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.tree, ctx.Err() +} + +type sequenceReadAllCapturer struct { + *fakeCapturer + caretTextReader + trees []*atspi.AccessibleTree + index int + invalidateTree *atspi.AccessibleTree + staleOnce bool + staleAt int + reads int + invalidateAfter bool +} + +func (capturer *sequenceReadAllCapturer) CaptureDocument(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) { + if capturer.index >= len(capturer.trees) { + return nil, errors.New("no more trees") + } + tree := capturer.trees[capturer.index] + capturer.index++ + return tree, nil +} + +func (capturer *sequenceReadAllCapturer) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + capturer.reads++ + if capturer.staleOnce || capturer.staleAt == capturer.reads { + capturer.staleOnce = false + capturer.invalidateTree.InvalidateSnapshot(reference.ID()) + return atspi.DocumentCaretText{}, atspi.ErrStaleSnapshot + } + result, err := capturer.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit) + if capturer.invalidateAfter { + capturer.invalidateAfter = false + capturer.invalidateTree.InvalidateSnapshot(reference.ID()) + } + return result, err +} + +func TestReadAllProgressRebindsEarlierLineAfterRecapture(t *testing.T) { + text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} + cursor := newCaretTextCursor(t, text) + fresh := newCaretTextCursor(t, text).tree + // Give the recaptured text a new generation, as happens when a provider + // removes and recreates the object while keeping its accessible path. + fresh.Invalidate(webID("/text")) + nodes := make([]atspi.TreeNode, 0, 3) + for _, path := range []string{"/doc", "/text", "/next"} { + snapshot := atspi.NewSnapshot(path, "", "static", "", "") + snapshot.BusName = webID(path).BusName + snapshot.CharacterCount = len([]rune(text[webID(path)])) + node := atspi.TreeNode{Snapshot: snapshot} + if path == "/doc" { + node.Snapshot.Role = "document web" + node.Children = []atspi.ObjectID{webID("/text"), webID("/next")} + } + nodes = append(nodes, node) + } + if err := fresh.Replace(webID("/doc"), nodes); err != nil { + t.Fatal(err) + } + capturer := &sequenceReadAllCapturer{ + caretTextReader: text, trees: []*atspi.AccessibleTree{fresh}, + invalidateTree: cursor.tree, staleAt: 2, + } + worker, _ := NewWorker(capturer, time.Second) + worker.SyncFocus(webID("/text")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil || len(result.ReadAllLines) < 2 { + t.Fatalf("read all after recapture: %+v", result) + } + result.ReadAllProgress(0) + request := <-worker.requests + if request.readTree != fresh { + t.Fatal("progress did not use recaptured tree") + } + followedCursor, followed := worker.followReadAll(context.Background(), cursor, request) + if !followed || followedCursor.places[len(followedCursor.places)-1].object != request.readPosition.Object { + t.Fatalf("first spoken line did not move the browse cursor after recapture: %+v", request.readPosition) + } + current, err := fresh.Reference(webID("/text")) + if err != nil || request.readPosition.Object != current { + t.Fatalf("spoken line did not rebind to the current object: %v, %v", request.readPosition.Object, err) + } +} + +func TestReadAllRetriesSecondStaleSnapshotCapture(t *testing.T) { + text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} + stale := newCaretTextCursor(t, text).tree + stale.InvalidateSnapshot(webID("/text")) + fresh := newCaretTextCursor(t, text).tree + capturer := &sequenceReadAllCapturer{caretTextReader: text, trees: []*atspi.AccessibleTree{stale, fresh}} + worker, _ := NewWorker(capturer, time.Second) + worker.SyncFocus(webID("/text")) + cursor := newCaretTextCursor(t, text) + cursor.tree.InvalidateSnapshot(webID("/text")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil || len(result.ReadAllLines) == 0 { + t.Fatalf("read all after stale snapshot retry: %+v", result) + } +} + +func TestReadAllRetriesStaleLineMovementWithoutSkipping(t *testing.T) { + text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} + cursor := newCaretTextCursor(t, text) + fresh := newCaretTextCursor(t, text).tree + secondFresh := newCaretTextCursor(t, text).tree + thirdFresh := newCaretTextCursor(t, text).tree + fourthFresh := newCaretTextCursor(t, text).tree + capturer := &sequenceReadAllCapturer{ + caretTextReader: text, + trees: []*atspi.AccessibleTree{fresh, secondFresh, thirdFresh, fourthFresh}, + invalidateTree: cursor.tree, + staleOnce: true, + } + worker, _ := NewWorker(capturer, time.Second) + worker.SyncFocus(webID("/text")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil || len(result.ReadAllLines) < 2 { + t.Fatalf("read all stale line movement retry: %+v", result) + } +} + +func TestReadAllContinuesWhenAncestorContextBecomesStale(t *testing.T) { + text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} + cursor := newCaretTextCursor(t, text) + fresh := newCaretTextCursor(t, text).tree + capturer := &sequenceReadAllCapturer{ + caretTextReader: text, + trees: []*atspi.AccessibleTree{fresh, fresh}, + invalidateTree: cursor.tree, + invalidateAfter: true, + } + worker, _ := NewWorker(capturer, time.Second) + worker.SyncFocus(webID("/text")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil || len(result.ReadAllLines) < 2 { + t.Fatalf("read all stale ancestor context: %+v", result) + } +} + +func (capturer *readAllCapturer) SetDocumentCaretPosition(context.Context, atspi.ObjectReference, atspi.ObjectID, int) error { + capturer.nativePlacements++ + return nil +} + +func TestReadAllStartsAtUnicodeOffsetAndPreservesCursorUntilSpeech(t *testing.T) { + text := caretTextReader{webID("/text"): "Café 猫\nSecond line\n", webID("/next"): "Last line"} + cursor := newCaretTextCursor(t, text) + cursor.places[0].offset = 5 + reader := &readAllCapturer{caretTextReader: text, tree: cursor.tree} + worker, _ := NewWorker(reader, time.Second) + worker.SyncFocus(webID("/text")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil { + t.Fatal(result.Err) + } + var lines []string + for _, line := range result.ReadAllLines { + lines = append(lines, strings.TrimSpace(line.Contents[0].Text)) + } + if got := strings.Join(lines, "|"); got != "猫|Second line|Last line" { + t.Fatalf("read all = %q", got) + } + if cursor.places[0].offset != 5 || cursor.places[0].object.ID() != webID("/text") { + t.Fatal("capture advanced user's cursor before speech") + } + result.ReadAllProgress(1) + request := <-worker.requests + cursor, _ = worker.followReadAll(context.Background(), cursor, request) + if cursor.places[0].offset != 7 { + t.Fatalf("speech progress offset = %d", cursor.places[0].offset) + } + if reader.nativePlacements != 0 { + t.Fatalf("read-all progress moved the native caret %d times", reader.nativePlacements) + } + resumed := worker.readAll(context.Background(), cursor) + if resumed.Err != nil || strings.TrimSpace(resumed.ReadAllLines[0].Contents[0].Text) != "Second line" { + t.Fatalf("resumed = %+v", resumed) + } +} + +func TestReadAllCancellation(t *testing.T) { + text := caretTextReader{webID("/text"): "First", webID("/next"): "Last"} + cursor := newCaretTextCursor(t, text) + worker, _ := NewWorker(&readAllCapturer{caretTextReader: text, tree: cursor.tree}, time.Second) + worker.SyncFocus(webID("/text")) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result := worker.readAll(ctx, cursor) + if !errors.Is(result.Err, context.Canceled) || len(result.ReadAllLines) != 0 { + t.Fatalf("canceled read all = %+v", result) + } +} diff --git a/internal/review/worker.go b/internal/review/worker.go new file mode 100644 index 0000000..7dc0727 --- /dev/null +++ b/internal/review/worker.go @@ -0,0 +1,374 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +const ( + requestCapacity = 16 + resultCapacity = 16 +) + +var ErrRequestQueueFull = errors.New("object review request queue is full") + +// Worker owns the runtime review cursor and every potentially blocking tree +// capture. Focus updates are coalesced to the newest identity. Navigation +// requests remain ordered. After a document timeout, queued document commands +// are retired so an unavailable provider cannot build a backlog of failures. +type Worker struct { + capturer TreeCapturer + captureTimeout time.Duration + flatCaptureTimeout time.Duration + + stateMutex sync.Mutex + latestFocus atspi.ObjectID + focusRevision uint64 + flatOrigin atspi.ObjectID + flatOffset int + started bool + cancel context.CancelFunc + done chan struct{} + + wakeFocus chan struct{} + requests chan request + results chan Result + lineSequence atomic.Uint64 + documentEpoch atomic.Uint64 + documentBraille atomic.Bool +} + +// SetDocumentBrailleEnabled avoids extra native line reads while braille is off. +func (worker *Worker) SetDocumentBrailleEnabled(enabled bool) { + worker.documentBraille.Store(enabled) +} + +// ResetDocument retires queued work and cursors for the previous page, even +// when the provider reuses accessible paths after navigation. +func (worker *Worker) ResetDocument(focus atspi.ObjectID) { + worker.stateMutex.Lock() + worker.latestFocus = focus + worker.focusRevision++ + worker.flatOrigin = atspi.ObjectID{} + worker.documentEpoch.Add(1) + worker.stateMutex.Unlock() + select { + case worker.wakeFocus <- struct{}{}: + default: + } +} + +func (worker *Worker) CurrentDocumentResult(result Result) bool { + return result.DocumentEpoch == worker.documentEpoch.Load() +} + +func (worker *Worker) DocumentEpoch() uint64 { return worker.documentEpoch.Load() } + +func NewWorker(capturer TreeCapturer, captureTimeout time.Duration) (*Worker, error) { + if capturer == nil { + return nil, errors.New("create object review worker: nil tree capturer") + } + if captureTimeout <= 0 { + return nil, errors.New("create object review worker: capture timeout must be positive") + } + return &Worker{ + capturer: capturer, + captureTimeout: captureTimeout, + flatCaptureTimeout: captureTimeout, + wakeFocus: make(chan struct{}, 1), + requests: make(chan request, requestCapacity), + results: make(chan Result, resultCapacity), + }, nil +} + +// SetFlatCaptureTimeout gives viewport review its own capture budget. Call it +// before Start; ordinary object and document operations keep their deadline. +func (worker *Worker) SetFlatCaptureTimeout(timeout time.Duration) error { + if timeout <= 0 { + return errors.New("flat review capture timeout must be positive") + } + worker.stateMutex.Lock() + defer worker.stateMutex.Unlock() + if worker.started { + return errors.New("cannot change flat review capture timeout after start") + } + worker.flatCaptureTimeout = timeout + return nil +} + +func (worker *Worker) Results() <-chan Result { + return worker.results +} + +func (worker *Worker) Start(ctx context.Context) error { + if ctx == nil { + return errors.New("start object review worker: nil context") + } + if err := ctx.Err(); err != nil { + return err + } + worker.stateMutex.Lock() + defer worker.stateMutex.Unlock() + if worker.started { + return errors.New("object review worker already started") + } + workerContext, cancel := context.WithCancel(ctx) + worker.started = true + worker.cancel = cancel + worker.done = make(chan struct{}) + go worker.run(workerContext, worker.done) + return nil +} + +func (worker *Worker) Stop(ctx context.Context) error { + if ctx == nil { + return errors.New("stop object review worker: nil context") + } + worker.stateMutex.Lock() + cancel, done := worker.cancel, worker.done + worker.stateMutex.Unlock() + if done == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// SyncFocus records the newest focused identity without waiting for capture. +// Invalid identities are ignored because some synthetic or shutdown events do +// not identify a live AT-SPI object. +func (worker *Worker) SyncFocus(focus atspi.ObjectID) { + if !focus.Valid() { + return + } + worker.stateMutex.Lock() + if worker.latestFocus != focus { + worker.focusRevision++ + worker.flatOrigin = atspi.ObjectID{} + } + worker.latestFocus = focus + worker.stateMutex.Unlock() + select { + case worker.wakeFocus <- struct{}{}: + default: + } +} + +// Recover a removed browse cursor only while it is still the current focus. +func (worker *Worker) recoverDocumentFocus(oldFocus, newFocus atspi.ObjectID, epoch uint64) bool { + worker.stateMutex.Lock() + defer worker.stateMutex.Unlock() + if worker.latestFocus != oldFocus || worker.documentEpoch.Load() != epoch { + return false + } + worker.latestFocus = newFocus + worker.focusRevision++ + return true +} + +// ClearFocus retires review ownership when the active window is deactivated +// or destroyed. Unlike SyncFocus, an invalid identity is meaningful here. +func (worker *Worker) ClearFocus() { + worker.stateMutex.Lock() + worker.latestFocus = atspi.ObjectID{} + worker.focusRevision++ + worker.flatOrigin = atspi.ObjectID{} + worker.documentEpoch.Add(1) + worker.stateMutex.Unlock() + select { + case worker.wakeFocus <- struct{}{}: + default: + } +} + +// Move queues a semantic navigation request without waiting for AT-SPI. +func (worker *Worker) Move(direction Direction) error { + if direction < Parent || direction > NextSibling { + return fmt.Errorf("%w: %d", ErrInvalidDirection, direction) + } + select { + case worker.requests <- request{direction: direction}: + return nil + default: + return ErrRequestQueueFull + } +} + +func (worker *Worker) run(ctx context.Context, done chan<- struct{}) { + defer close(done) + var cursor *Cursor + var document *DocumentCursor + var documentTextTree *atspi.AccessibleTree + var documentEpoch uint64 + var failedDocumentBefore time.Time + var interestFocus atspi.ObjectID + var interestRevision uint64 + explicitInterest := implicitInterest + flat := &FlatCursor{} + var flatTree *atspi.AccessibleTree + for { + // A ready request/result channel can win a select even after Stop. + // Do not drain queued work or start further captures during shutdown. + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-worker.wakeFocus: + if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil { + cursor = next + } + case request := <-worker.requests: + worker.stateMutex.Lock() + focus, revision := worker.latestFocus, worker.focusRevision + worker.stateMutex.Unlock() + if revision != interestRevision || focus != interestFocus { + interestFocus, interestRevision, explicitInterest = focus, revision, implicitInterest + } + if epoch := worker.documentEpoch.Load(); epoch != documentEpoch { + document, documentTextTree = nil, nil + explicitInterest = implicitInterest + flat, flatTree = &FlatCursor{}, nil + documentEpoch = epoch + failedDocumentBefore = time.Time{} + } + if request.interest != nil { + interest, err := resolve_interest(request.interest.ctx, interestFocus, explicitInterest, cursor, flat, flatTree, document) + worker.stateMutex.Lock() + currentFocus, currentRevision := worker.latestFocus, worker.focusRevision + worker.stateMutex.Unlock() + if currentFocus != interestFocus || currentRevision != interestRevision { + interest, err = Interest{}, ErrNoCurrent + } + if contextErr := request.interest.ctx.Err(); contextErr != nil { + interest, err = Interest{}, contextErr + } + request.interest.result <- interestReply{interest, err} + continue + } + if (request.brailleRoute != nil || request.nativeCaret || request.readAll != nil || request.line || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" || request.unit != 0) && request.documentEpoch != documentEpoch { + if request.brailleRoute != nil { + request.brailleRoute.done <- atspi.ErrStaleObject + } + continue + } + documentCommand := request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" + if documentCommand && request.queuedAt.Before(failedDocumentBefore) { + continue + } + var result Result + if request.brailleRoute != nil { + result = worker.route_document_braille(ctx, document, request) + } else if request.nativeCaret { + result, document = worker.syncDocumentCaret(ctx, document, request) + } else if request.readPosition != nil { + if request.documentEpoch != documentEpoch || request.readFocus != worker.focus() { + continue + } + var followed bool + document, followed = worker.followReadAll(ctx, document, request) + if !followed { + continue + } + result = Result{ReadAllPosition: true, DocumentFocus: request.readFocus, DocumentPosition: *request.readPosition} + } else if request.readAll != nil { + readContext, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(request.readAll, cancel) + result = worker.readAll(readContext, document) + stop() + cancel() + result.ReadAllContext = request.readAll + } else if request.line { + result, documentTextTree = worker.readDocumentLine(ctx, documentTextTree, request.lineRequest, request.lineFocus, request.lineOffset) + result.Elapsed = time.Since(request.queuedAt) + } else if request.target != 0 { + result, document = worker.moveDocument(ctx, document, request.target, request.level, request.backwards, request.documentEpoch) + result.Elapsed = time.Since(request.queuedAt) + } else if request.caretCommand != 0 { + result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch) + result.Elapsed = time.Since(request.queuedAt) + } else if request.activate { + result, document = worker.activateDocumentCaret(ctx, document, request.documentEpoch) + result.Elapsed = time.Since(request.queuedAt) + } else if request.fragment != "" { + result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch) + result.Elapsed = time.Since(request.queuedAt) + } else if request.unit != 0 { + result, flatTree = worker.readFlat(ctx, flat, flatTree, request.unit, request.step) + result.FlatSpeech = request.flatSpeech + } else { + movement := worker.move(ctx, cursor, request.direction) + cursor = movement.cursor + result = movement.Result + } + if result.Err == nil { + if request.unit != 0 { + explicitInterest = flatInterest + } else if request.direction != 0 { + explicitInterest = objectInterest + } else if request.brailleRoute != nil || request.target != 0 || request.caretCommand != 0 || request.nativeCaret || request.fragment != "" || request.readPosition != nil { + explicitInterest = implicitInterest + } + worker.stateMutex.Lock() + if worker.latestFocus != interestFocus || worker.focusRevision != interestRevision { + explicitInterest = implicitInterest + } + interestFocus, interestRevision = worker.latestFocus, worker.focusRevision + worker.stateMutex.Unlock() + } + result.DocumentEpoch = request.documentEpoch + if documentCommand && worker.CurrentDocumentResult(result) && errors.Is(result.Err, context.DeadlineExceeded) { + // Keys pressed while the provider was unavailable must not each + // start another full timeout. A command issued after this failure + // can try again; successful navigation keeps its ordered steps. + failedDocumentBefore = time.Now() + } + if request.brailleRoute != nil || request.target != 0 || request.caretCommand != 0 || request.fragment != "" { + result = worker.readDocumentBraille(ctx, result) + result.Elapsed = time.Since(request.queuedAt) + } + select { + case worker.results <- result: + case <-ctx.Done(): + return + } + } + } +} + +func (worker *Worker) focus() atspi.ObjectID { + worker.stateMutex.Lock() + defer worker.stateMutex.Unlock() + return worker.latestFocus +} + +func (worker *Worker) Focus() atspi.ObjectID { return worker.focus() } diff --git a/internal/review/worker_contract.go b/internal/review/worker_contract.go new file mode 100644 index 0000000..9d54266 --- /dev/null +++ b/internal/review/worker_contract.go @@ -0,0 +1,134 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// TreeCapturer performs native AT-SPI capture. Worker is the only runtime +// caller so capture latency cannot block the event or command loops. +type TreeCapturer interface { + CaptureDesktop(context.Context) (*atspi.AccessibleTree, error) + CaptureReviewWindow(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) + CaptureDocument(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) + CaptureDocumentText(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) +} + +// Fresh providers cannot reuse text from a previous review command. +type FreshReviewWindowCapturer interface{ FreshReviewWindow(atspi.ObjectID) bool } + +// Resolve optional native operations without hiding the adapter's dynamic +// capabilities behind a capture router. Reserved provider IDs return nil. +type NativeCapturerResolver interface { + NativeCapturerFor(atspi.ObjectID) TreeCapturer +} + +func (worker *Worker) nativeCapturerFor(id atspi.ObjectID) TreeCapturer { + if resolver, ok := worker.capturer.(NativeCapturerResolver); ok { + return resolver.NativeCapturerFor(id) + } + return worker.capturer +} + +// Result describes one requested navigation step. A boundary has Moved false +// and no error; Position remains the current object in that case. +type Result struct { + ReadAllContext context.Context + ReadAllLines []atspi.DocumentLine + ReadAllProgress func(int) + ReadAllPosition bool + DocumentEpoch uint64 + Elapsed time.Duration + CaptureDuration time.Duration + CaptureReason error + DocumentTarget DocumentTarget + DocumentCaretCommand DocumentCaretCommand + DocumentActivate bool + DocumentFragment bool + DocumentCaretText string + DocumentCaretRawText string + DocumentCaretLine *atspi.DocumentLine + DocumentTablePrefix string + DocumentTableSuffix string + + DocumentTableRowHeader, DocumentTableColumnHeader string + + DocumentCaretStart int + DocumentCaretEnd int + DocumentCaretContext []atspi.Snapshot + DocumentBrailleLine *atspi.DocumentCaretText + DocumentBrailleErr error + DocumentBackwards bool + DocumentLevel int + DocumentWrapped bool + DocumentNativeCaret bool + DocumentBrailleRoute bool + DocumentPosition DocumentPosition + DocumentFocus atspi.ObjectID + Direction Direction + Position Position + Moved bool + Err error + CaretErr error + FlatUnit FlatUnit + FlatSpeech FlatSpeech + FlatStep int + FlatPosition FlatPosition + FlatFocus atspi.ObjectID + DocumentLine bool + DocumentLineRequest uint64 + DocumentLineData atspi.DocumentLine + DocumentLineFocus atspi.ObjectID + DocumentLineText string +} + +// request uses the existing ordered dispatch contract: native caret, Say All +// progress, Say All capture, document line, structural target, caret command, +// activation, fragment, flat unit, then object direction. Flags are deliberately +// retained; epoch and timeout retirement depend on their distinct combinations. +type request struct { + brailleRoute *documentBrailleRequest + interest *interestRequest + nativeCaret bool + nativeCaretFocus atspi.ObjectID + nativeCaretOffset int + readAll context.Context + readPosition *DocumentPosition + readTree *atspi.AccessibleTree + readFocus atspi.ObjectID + documentEpoch uint64 + queuedAt time.Time + target DocumentTarget + caretCommand DocumentCaretCommand + activate bool + fragment string + backwards bool + level int + direction Direction + unit FlatUnit + flatSpeech FlatSpeech + step int + line bool + lineRequest uint64 + lineFocus atspi.ObjectID + lineOffset int +} diff --git a/internal/review/worker_test.go b/internal/review/worker_test.go new file mode 100644 index 0000000..0529044 --- /dev/null +++ b/internal/review/worker_test.go @@ -0,0 +1,405 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package review + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type captureCall struct { + ctx context.Context + reviewFocus atspi.ObjectID + result chan captureResult +} + +type captureResult struct { + tree *atspi.AccessibleTree + err error +} + +type fakeCapturer struct { + calls chan captureCall +} + +func (capturer *fakeCapturer) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, atspi.ObjectID{}) +} + +func (capturer *fakeCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, focus) +} + +func (capturer *fakeCapturer) CaptureDocument(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, focus) +} + +func (capturer *fakeCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { + return capturer.capture(ctx, focus) +} + +func (capturer *fakeCapturer) capture(ctx context.Context, reviewFocus atspi.ObjectID) (*atspi.AccessibleTree, error) { + call := captureCall{ctx: ctx, reviewFocus: reviewFocus, result: make(chan captureResult, 1)} + capturer.calls <- call + select { + case result := <-call.result: + return result.tree, result.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func startWorker(t *testing.T, capturer TreeCapturer, timeout time.Duration) (*Worker, context.CancelFunc) { + t.Helper() + worker, err := NewWorker(capturer, timeout) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := worker.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + return worker, cancel +} + +func waitResult(t *testing.T, worker *Worker) Result { + t.Helper() + select { + case result := <-worker.Results(): + return result + case <-time.After(time.Second): + t.Fatal("timed out waiting for object review result") + return Result{} + } +} + +func TestWorkerCaptureTimeoutDoesNotBlockCallers(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, 20*time.Millisecond) + defer cancel() + + worker.SyncFocus(objectID("/first")) + if err := worker.Move(NextSibling); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + select { + case <-call.ctx.Done(): + case <-time.After(time.Second): + t.Fatal("capture context did not time out") + } + result := waitResult(t, worker) + if !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatalf("result error = %v, want deadline exceeded", result.Err) + } +} + +func TestWorkerReadsDocumentLineFromTextCapture(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + + focus := objectID("/link") + request, err := worker.ReadDocumentLine(focus, 6) + if err != nil { + t.Fatal(err) + } + call := <-capturer.calls + if call.reviewFocus != focus { + t.Fatalf("capture focus = %#v", call.reviewFocus) + } + call.result <- captureResult{tree: documentLineTree(t)} + result := waitResult(t, worker) + if result.Err != nil || !result.DocumentLine || !result.Moved { + t.Fatalf("line result = %#v", result) + } + if result.DocumentLineFocus != focus { + t.Fatalf("result focus = %#v", result.DocumentLineFocus) + } + if result.DocumentLineRequest != request { + t.Fatalf("result request = %d, want %d", result.DocumentLineRequest, request) + } + var contents []string + for _, content := range result.DocumentLineData.Contents { + contents = append(contents, content.Text) + } + if !reflect.DeepEqual(contents, []string{"Read ", "the link"}) { + t.Fatalf("line contents = %#v", contents) + } +} + +func TestWorkerReusesDocumentTextCaptureForCaretLines(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 2)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + + focus := objectID("/link") + if _, err := worker.ReadDocumentLine(focus, 1); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + call.result <- captureResult{tree: documentLineTree(t)} + if result := waitResult(t, worker); result.Err != nil { + t.Fatalf("first line result = %#v", result) + } + + if _, err := worker.ReadDocumentLine(focus, 2); err != nil { + t.Fatal(err) + } + if result := waitResult(t, worker); result.Err != nil { + t.Fatalf("cached line result = %#v", result) + } + select { + case call := <-capturer.calls: + t.Fatalf("unexpected repeated document capture for %#v", call.reviewFocus) + case <-time.After(20 * time.Millisecond): + } +} + +func TestWorkerUsesNewestFocusAfterConcurrentCapture(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + + worker.SyncFocus(objectID("/first")) + if err := worker.Move(NextSibling); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + worker.SyncFocus(objectID("/second")) + call.result <- captureResult{tree: threeNodeTree(t)} + result := waitResult(t, worker) + if result.Err != nil || !result.Moved || result.Position.Current.Name != "Third" { + t.Fatalf("result = %#v, want move from newest focus to Third", result) + } +} + +func TestWorkerRecapturesAndRepairsStaleCursor(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 2)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + + firstTree := threeNodeTree(t) + worker.SyncFocus(objectID("/first")) + if err := worker.Move(NextSibling); err != nil { + t.Fatal(err) + } + firstCall := <-capturer.calls + firstCall.result <- captureResult{tree: firstTree} + if result := waitResult(t, worker); result.Err != nil || result.Position.Current.Name != "Second" { + t.Fatalf("initial result = %#v", result) + } + firstTree.Invalidate(objectID("/second")) + + if err := worker.Move(NextSibling); err != nil { + t.Fatal(err) + } + secondCall := <-capturer.calls + secondCall.result <- captureResult{tree: threeNodeTree(t)} + result := waitResult(t, worker) + if result.Err != nil || !result.Moved || result.Position.Current.Name != "Second" { + t.Fatalf("recovered result = %#v, want Second", result) + } +} + +func TestWorkerStopsCleanlyDuringCapture(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Minute) + worker.SyncFocus(objectID("/first")) + if err := worker.Move(NextSibling); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + cancel() + stopContext, stopCancel := context.WithTimeout(context.Background(), time.Second) + defer stopCancel() + if err := worker.Stop(stopContext); err != nil { + t.Fatal(err) + } + select { + case <-call.ctx.Done(): + default: + t.Fatal("shutdown did not cancel in-flight capture") + } +} + +func documentLineTree(t *testing.T) *atspi.AccessibleTree { + t.Helper() + baseNode := func(path, name, role string, children ...atspi.ObjectID) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, name, role, "", "") + snapshot.BusName = ":1.42" + return atspi.TreeNode{Snapshot: snapshot, Children: children} + } + lineText := func(path, role, text string, bounds atspi.Rectangle, caret bool) atspi.TreeNode { + snapshot := atspi.NewSnapshot(path, path, role, "", text) + snapshot.BusName = ":1.42" + geometry := atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} + visible := atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{ + Start: 0, End: len(text), Text: text, Bounds: bounds, + }}} + if caret { + visible.CaretKnown, visible.CaretOffset = true, 1 + } + return atspi.TreeNode{Snapshot: snapshot, Geometry: geometry, Text: visible} + } + tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{ + baseNode("/root", "Window", "frame", objectID("/doc")), + baseNode("/doc", "Document", "document web", objectID("/text"), objectID("/link")), + lineText("/text", "static", "Read ", atspi.Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, false), + lineText("/link", "link", "the link", atspi.Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, false), + }) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestWorkerAcceptsConcurrentFocusUpdates(t *testing.T) { + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, cancel := startWorker(t, capturer, time.Second) + defer cancel() + var wait sync.WaitGroup + for index := 0; index < 20; index++ { + wait.Add(1) + go func() { + defer wait.Done() + worker.SyncFocus(objectID("/second")) + }() + } + wait.Wait() + if err := worker.Move(NextSibling); err != nil { + t.Fatal(err) + } + call := <-capturer.calls + call.result <- captureResult{tree: threeNodeTree(t)} + result := waitResult(t, worker) + if result.Err != nil || result.Position.Current.Name != "Third" { + t.Fatalf("result = %#v", result) + } +} + +func threeNodeTree(t *testing.T) *atspi.AccessibleTree { + t.Helper() + tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{ + node("/root", "Window", objectID("/first"), objectID("/second"), objectID("/third")), + node("/first", "First"), + node("/second", "Second"), + node("/third", "Third"), + }) + if err != nil { + t.Fatal(err) + } + return tree +} + +func TestWorkerRecapturesIncompleteChildrenAtIndependentPosition(t *testing.T) { + for _, outcome := range []string{"added", "empty", "failure", "focus-changed", "invalidated-again"} { + t.Run(outcome, func(t *testing.T) { + tree := threeNodeTree(t) + cursor, err := NewCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(objectID("/first")); err != nil { + t.Fatal(err) + } + if _, _, err := cursor.Move(NextSibling); err != nil { + t.Fatal(err) + } + tree.InvalidateChildren(objectID("/second")) + capturer := &fakeCapturer{calls: make(chan captureCall, 1)} + worker, err := NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(objectID("/first")) + results := make(chan moveResult, 1) + go func() { results <- worker.move(context.Background(), cursor, FirstChild) }() + var call captureCall + select { + case call = <-capturer.calls: + case result := <-results: + t.Fatalf("navigation did not recapture: %#v", result) + case <-time.After(time.Second): + t.Fatal("capture did not start") + } + captureErr := errors.New("provider unavailable") + if outcome == "failure" { + call.result <- captureResult{err: captureErr} + } else { + nodes := []atspi.TreeNode{ + node("/root", "Window", objectID("/first"), objectID("/second")), + node("/first", "First"), + node("/second", "Second"), + } + if outcome != "empty" { + nodes[2].Children = []atspi.ObjectID{objectID("/added")} + nodes = append(nodes, node("/added", "Added")) + } + if err := tree.Replace(objectID("/root"), nodes); err != nil { + t.Fatal(err) + } + if outcome == "focus-changed" { + worker.SyncFocus(objectID("/root")) + } + if outcome == "invalidated-again" { + tree.InvalidateChildren(objectID("/second")) + } + call.result <- captureResult{tree: tree} + } + var result moveResult + select { + case result = <-results: + case <-time.After(2 * time.Second): + t.Fatal("navigation did not finish after one recapture") + } + switch outcome { + case "failure": + if !errors.Is(result.Err, captureErr) || result.cursor != cursor { + t.Fatalf("failed recovery = %#v", result) + } + position, err := cursor.Current() + if err != nil || position.Current.Name != "Second" { + t.Fatalf("lost review position: %#v %v", position, err) + } + case "invalidated-again": + if !errors.Is(result.Err, atspi.ErrIncompleteTree) { + t.Fatalf("repeated invalidation = %#v", result) + } + default: + want, moved := "Added", true + if outcome == "empty" { + want, moved = "Second", false + } + if outcome == "focus-changed" { + want = "First" + } + if result.Err != nil || result.Moved != moved || result.Position.Current.Name != want { + t.Fatalf("result = %#v, want %s moved=%v", result, want, moved) + } + } + }) + } +} diff --git a/internal/sessionbus/sessionbus.go b/internal/sessionbus/sessionbus.go new file mode 100644 index 0000000..f8c4ae9 --- /dev/null +++ b/internal/sessionbus/sessionbus.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package sessionbus selects the same desktop session bus as GLib clients, +// including Python Cthulhu and I38lock, without changing the process environment. +package sessionbus + +/* +#cgo pkg-config: gio-2.0 +#include +*/ +import "C" + +import ( + "context" + "fmt" + "log/slog" + "time" + "unsafe" + + "github.com/godbus/dbus/v5" +) + +func sessionAddress() (string, error) { + var nativeError *C.GError + address := C.g_dbus_address_get_for_bus_sync(C.G_BUS_TYPE_SESSION, nil, &nativeError) + if nativeError != nil { + defer C.g_error_free(nativeError) + return "", fmt.Errorf("resolve desktop session bus: %s", C.GoString(nativeError.message)) + } + if address == nil { + return "", fmt.Errorf("resolve desktop session bus: no address") + } + defer C.g_free(C.gpointer(unsafe.Pointer(address))) + return C.GoString(address), nil +} + +// Connect uses GLib's session address discovery and godbus's private connection. +// Bus IDs let debug logs identify mismatched buses without exposing addresses. +func Connect(logger *slog.Logger, component string) (*dbus.Conn, error) { + address, err := sessionAddress() + if err != nil { + return nil, err + } + connection, err := dbus.Connect(address) + if err != nil { + return nil, err + } + if logger != nil && logger.Enabled(context.Background(), slog.LevelDebug) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + var busID string + if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetId", 0).Store(&busID); err != nil { + logger.Debug("query session bus identity", "component", component, "error", err) + } else { + logger.Debug("connected to session bus", "component", component, "bus_id", busID) + } + } + return connection, nil +} diff --git a/internal/sessionbus/sessionbus_test.go b/internal/sessionbus/sessionbus_test.go new file mode 100644 index 0000000..2658b8d --- /dev/null +++ b/internal/sessionbus/sessionbus_test.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sessionbus + +import ( + "bufio" + "bytes" + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestSessionBusHelper(t *testing.T) { + expectedID := os.Getenv("CTHULHU_SESSIONBUS_EXPECTED_ID") + if expectedID == "" { + t.Skip("subprocess helper") + } + before, present := os.LookupEnv("DBUS_SESSION_BUS_ADDRESS") + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + connection, err := Connect(logger, "test") + if err != nil { + t.Fatal(err) + } + defer connection.Close() + var busID string + if err := connection.BusObject().Call("org.freedesktop.DBus.GetId", 0).Store(&busID); err != nil { + t.Fatal(err) + } + if busID != expectedID { + t.Fatalf("selected bus %q, want %q", busID, expectedID) + } + if after, exists := os.LookupEnv("DBUS_SESSION_BUS_ADDRESS"); after != before || exists != present { + t.Fatal("session discovery changed process environment") + } + if !strings.Contains(output.String(), `"bus_id":"`+expectedID+`"`) { + t.Fatal("missing bus identity diagnostic") + } + if strings.Contains(output.String(), os.Getenv("XDG_RUNTIME_DIR")) { + t.Fatal("diagnostic exposes runtime directory") + } +} + +func TestNativeDesktopSessionBusDiscovery(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("requires private session bus sockets") + } + startBus := func() (string, string, string) { + runtimeDirectory := t.TempDir() + process := exec.Command("dbus-daemon", "--session", "--nofork", "--address=unix:path="+filepath.Join(runtimeDirectory, "bus"), "--print-address=1") + stdout, err := process.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := process.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { process.Process.Kill(); process.Wait() }) + address, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil { + t.Fatal(err) + } + connection, err := dbus.Connect(strings.TrimSpace(address)) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + var busID string + if err := connection.BusObject().CallWithContext(context.Background(), "org.freedesktop.DBus.GetId", 0).Store(&busID); err != nil { + t.Fatal(err) + } + return runtimeDirectory, strings.TrimSpace(address), busID + } + runtimeDirectory, _, runtimeID := startBus() + _, explicitAddress, explicitID := startBus() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, explicit := range []bool{false, true} { + expectedID := runtimeID + environment := make([]string, 0) + for _, value := range os.Environ() { + if !strings.HasPrefix(value, "DBUS_SESSION_BUS_ADDRESS=") && !strings.HasPrefix(value, "XDG_RUNTIME_DIR=") { + environment = append(environment, value) + } + } + if explicit { + environment = append(environment, "DBUS_SESSION_BUS_ADDRESS="+explicitAddress) + expectedID = explicitID + } + environment = append(environment, "XDG_RUNTIME_DIR="+runtimeDirectory, "CTHULHU_SESSIONBUS_EXPECTED_ID="+expectedID) + process := exec.Command(executable, "-test.run=^TestSessionBusHelper$", "-test.v") + process.Env = environment + if output, err := process.CombinedOutput(); err != nil { + t.Fatalf("explicit=%v: %v\n%s", explicit, err, output) + } + } +} diff --git a/internal/shortcutui/native.c b/internal/shortcutui/native.c new file mode 100644 index 0000000..62615fb --- /dev/null +++ b/internal/shortcutui/native.c @@ -0,0 +1,51 @@ +#include + +int shortcut_ui_open(const char *title, const char **groups, const char **labels, const char **keys, int count) { + if (!gtk_init_check(NULL, NULL)) return 0; + GtkWidget *dialog = gtk_dialog_new_with_buttons(title, NULL, GTK_DIALOG_MODAL, + "_Close", GTK_RESPONSE_CLOSE, NULL); + gtk_window_set_default_size(GTK_WINDOW(dialog), 900, 700); + GtkWidget *scrolled = gtk_scrolled_window_new(NULL, NULL); + gtk_widget_set_vexpand(scrolled, TRUE); + gtk_widget_set_hexpand(scrolled, TRUE); + gtk_container_add(GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))), scrolled); + GtkTreeStore *store = gtk_tree_store_new(2, G_TYPE_STRING, G_TYPE_STRING); + GtkWidget *tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(store)); + gtk_widget_set_can_focus(tree, TRUE); + atk_object_set_name(gtk_widget_get_accessible(tree), "Cthulhu shortcuts"); + gtk_tree_view_set_headers_visible(GTK_TREE_VIEW(tree), TRUE); + const char *headers[] = {"Function", "Key binding"}; + for (int column = 0; column < 2; column++) { + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn *view = gtk_tree_view_column_new_with_attributes(headers[column], renderer, + "text", column, NULL); + gtk_tree_view_column_set_sort_column_id(view, column); + gtk_tree_view_append_column(GTK_TREE_VIEW(tree), view); + } + gtk_container_add(GTK_CONTAINER(scrolled), tree); + GtkTreeIter parent, child, first; + gboolean have_first = FALSE; + const char *previous = NULL; + for (int i = 0; i < count; i++) { + if (!previous || g_strcmp0(previous, groups[i]) != 0) { + gtk_tree_store_append(store, &parent, NULL); + gtk_tree_store_set(store, &parent, 0, groups[i], 1, "", -1); + previous = groups[i]; + } + gtk_tree_store_append(store, &child, &parent); + gtk_tree_store_set(store, &child, 0, labels[i], 1, keys[i], -1); + if (!have_first) { first = child; have_first = TRUE; } + } + gtk_tree_view_expand_all(GTK_TREE_VIEW(tree)); + gtk_widget_show_all(dialog); + if (have_first) { + GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(store), &first); + gtk_tree_view_set_cursor(GTK_TREE_VIEW(tree), path, NULL, FALSE); + gtk_tree_path_free(path); + } + gtk_widget_grab_focus(tree); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + g_object_unref(store); + return 1; +} diff --git a/internal/shortcutui/ui.go b/internal/shortcutui/ui.go new file mode 100644 index 0000000..5a861e0 --- /dev/null +++ b/internal/shortcutui/ui.go @@ -0,0 +1,55 @@ +package shortcutui + +/* +#cgo pkg-config: gtk+-3.0 +#include +int shortcut_ui_open(const char *title, const char **groups, const char **labels, const char **keys, int count); +*/ +import "C" + +import ( + "encoding/json" + "errors" + "io" + "runtime" + "unsafe" +) + +type Row struct { + Group string `json:"group"` + Label string `json:"label"` + Keys string `json:"keys"` +} + +type List struct { + Title string `json:"title"` + Rows []Row `json:"rows"` +} + +// Run displays one read-only, keyboard accessible shortcut list in a child process. +func Run(reader io.Reader) error { + var list List + if err := json.NewDecoder(io.LimitReader(reader, 1<<20)).Decode(&list); err != nil { + return err + } + if len(list.Rows) == 0 || len(list.Rows) > 512 { + return errors.New("invalid shortcut list") + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + title := C.CString(list.Title) + defer C.free(unsafe.Pointer(title)) + groups := make([]*C.char, len(list.Rows)) + labels := make([]*C.char, len(list.Rows)) + keys := make([]*C.char, len(list.Rows)) + for i, row := range list.Rows { + groups[i], labels[i], keys[i] = C.CString(row.Group), C.CString(row.Label), C.CString(row.Keys) + defer C.free(unsafe.Pointer(groups[i])) + defer C.free(unsafe.Pointer(labels[i])) + defer C.free(unsafe.Pointer(keys[i])) + } + if C.shortcut_ui_open(title, (**C.char)(unsafe.Pointer(&groups[0])), (**C.char)(unsafe.Pointer(&labels[0])), (**C.char)(unsafe.Pointer(&keys[0])), C.int(len(list.Rows))) == 0 { + return errors.New("could not open shortcut list window") + } + return nil +} diff --git a/internal/shortcutui/ui_native_test.go b/internal/shortcutui/ui_native_test.go new file mode 100644 index 0000000..68f4617 --- /dev/null +++ b/internal/shortcutui/ui_native_test.go @@ -0,0 +1,39 @@ +package shortcutui + +import ( + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeShortcutListOpensAndClosesFromKeyboard(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_X11") != "1" { + t.Skip("requires isolated X11 display") + } + finished := make(chan error, 1) + go func() { + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + if output, err := exec.Command("xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Shortcut Test$").Output(); err == nil && len(strings.TrimSpace(string(output))) != 0 { + window := strings.Fields(string(output))[0] + if err := exec.Command("xdotool", "windowfocus", window).Run(); err != nil { + finished <- err + return + } + finished <- exec.Command("xdotool", "key", "Escape").Run() + return + } + time.Sleep(20 * time.Millisecond) + } + finished <- os.ErrDeadlineExceeded + }() + data := `{"title":"Cthulhu Shortcut Test","rows":[{"group":"Default","label":"Quit Cthulhu.","keys":"Cthulhu+q"}]}` + if err := Run(strings.NewReader(data)); err != nil { + t.Fatal(err) + } + if err := <-finished; err != nil { + t.Fatal(err) + } +} diff --git a/internal/sound/indentation.go b/internal/sound/indentation.go new file mode 100644 index 0000000..eed0991 --- /dev/null +++ b/internal/sound/indentation.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "strconv" + "strings" + "time" +) + +const indentationCuePrefix = "generated-indentation-tone:" + +func IndentationCueName(frequency int) string { return indentationCuePrefix + strconv.Itoa(frequency) } +func indentation_frequency(name string) (int, bool) { + if !strings.HasPrefix(name, indentationCuePrefix) { + return 0, false + } + frequency, err := strconv.Atoi(strings.TrimPrefix(name, indentationCuePrefix)) + return frequency, err == nil && frequency >= 100 && frequency <= 2750 +} +func indentation_tone_wav(frequency int) []byte { + return tone_wav(float64(frequency), 150*time.Millisecond, .7, 5*time.Millisecond) +} diff --git a/internal/sound/indentation_test.go b/internal/sound/indentation_test.go new file mode 100644 index 0000000..d715545 --- /dev/null +++ b/internal/sound/indentation_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "encoding/binary" + "log/slog" + "testing" +) + +func TestIndentationToneIsBoundedAndHonorsSoundPolicy(t *testing.T) { + service := New(slog.New(slog.DiscardHandler), nil, "default", .5) + for _, frequency := range []int{100, 200, 2750} { + name := IndentationCueName(frequency) + data, err := soundData("default", name) + if err != nil || len(data) != 44+3307*2 || binary.LittleEndian.Uint32(data[24:]) != 22050 { + t.Fatalf("indentation WAV: bytes %d, error %v", len(data), err) + } + if !service.cue_available(name) || !generated_tone(name) { + t.Fatal("generated indentation unavailable") + } + } + if _, err := soundData("default", IndentationCueName(2751)); err == nil { + t.Fatal("unbounded pitch accepted") + } + service.Configure(Configuration{Enabled: false, Theme: "default", Volume: .5}) + if service.cue_available(IndentationCueName(200)) { + t.Fatal("disabled sound played indentation") + } + service.Configure(Configuration{Enabled: true, Theme: "none", Volume: .5}) + if service.cue_available(IndentationCueName(200)) { + t.Fatal("None theme played indentation") + } +} diff --git a/internal/sound/player.go b/internal/sound/player.go new file mode 100644 index 0000000..710c9e6 --- /dev/null +++ b/internal/sound/player.go @@ -0,0 +1,110 @@ +package sound + +/* +#cgo pkg-config: gstreamer-1.0 +#include +#include +static GstElement *start_sound(const void *data, size_t length, double volume, double pan, const char *sink, GError **error) { + if (!gst_init_check(NULL, NULL, error)) return NULL; + gchar *description = g_strdup_printf("appsrc name=source ! wavparse ! audioconvert ! audioresample ! audiopanorama name=panorama ! volume name=volume ! %s", sink); + GError *parse_error = NULL; + GstElement *pipeline = gst_parse_launch(description, &parse_error); + g_free(description); + if (parse_error != NULL) { + if (pipeline) gst_object_unref(pipeline); + g_error_free(parse_error); + // audiopanorama is an optional GStreamer plugin. Preserve playback + // when it is unavailable; the typed cue remains safely centered. + description = g_strdup_printf("appsrc name=source ! wavparse ! audioconvert ! audioresample ! volume name=volume ! %s", sink); + pipeline = gst_parse_launch(description, error); + g_free(description); + if (*error != NULL) { if (pipeline) gst_object_unref(pipeline); return NULL; } + } + GstElement *source = gst_bin_get_by_name(GST_BIN(pipeline), "source"); + GstElement *gain = gst_bin_get_by_name(GST_BIN(pipeline), "volume"); + GstElement *panorama = gst_bin_get_by_name(GST_BIN(pipeline), "panorama"); + g_object_set(gain, "volume", volume, NULL); + if (panorama != NULL) g_object_set(panorama, "panorama", pan, NULL); + gst_object_unref(gain); + if (panorama != NULL) gst_object_unref(panorama); + GstBuffer *buffer = gst_buffer_new_allocate(NULL, length, NULL); + gst_buffer_fill(buffer, 0, data, length); + GstFlowReturn flow; + g_signal_emit_by_name(source, "push-buffer", buffer, &flow); + gst_buffer_unref(buffer); + g_signal_emit_by_name(source, "end-of-stream", &flow); + gst_object_unref(source); + if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + g_set_error_literal(error, g_quark_from_static_string("cthulhu-sound"), 1, "cannot start audio pipeline"); + gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); return NULL; + } + return pipeline; +} +static int poll_sound(GstElement *pipeline, GError **error) { + GstBus *bus = gst_element_get_bus(pipeline); + GstMessage *message = gst_bus_timed_pop_filtered(bus, 20 * GST_MSECOND, GST_MESSAGE_ERROR | GST_MESSAGE_EOS); + gst_object_unref(bus); + if (message == NULL) return 0; + int result = 1; + if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + gst_message_parse_error(message, error, NULL); result = -1; + } + gst_message_unref(message); return result; +} +static void stop_sound(GstElement *pipeline) { + gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); +} +*/ +import "C" + +import ( + "context" + "errors" + "fmt" + "unsafe" +) + +type Player interface { + Play(context.Context, []byte, float64, float64) error +} + +type NativePlayer struct{ Sink string } + +func (player NativePlayer) Play(ctx context.Context, data []byte, volume, pan float64) error { + if err := ctx.Err(); err != nil { + return err + } + if len(data) == 0 { + return errors.New("empty sound") + } + sink := map[string]string{"auto": "autoaudiosink", "pipewire": "pipewiresink", "pulse": "pulsesink", "alsa": "alsasink", "test": "fakesink"}[player.Sink] + if sink == "" { + return fmt.Errorf("unknown sound sink %q", player.Sink) + } + nativeSink := C.CString(sink) + defer C.free(unsafe.Pointer(nativeSink)) + var nativeErr *C.GError + pan = normalizedPan(pan) + pipeline := C.start_sound(unsafe.Pointer(&data[0]), C.size_t(len(data)), C.double(volume), C.double(pan), nativeSink, &nativeErr) + if nativeErr != nil { + defer C.g_error_free(nativeErr) + return errors.New(C.GoString(nativeErr.message)) + } + if pipeline == nil { + return errors.New("cannot create sound pipeline") + } + defer C.stop_sound(pipeline) + for { + if err := ctx.Err(); err != nil { + return err + } + result := C.poll_sound(pipeline, &nativeErr) + if nativeErr != nil { + defer C.g_error_free(nativeErr) + return errors.New(C.GoString(nativeErr.message)) + } + if result > 0 { + return nil + } + } +} diff --git a/internal/sound/position.go b/internal/sound/position.go new file mode 100644 index 0000000..c19b8de --- /dev/null +++ b/internal/sound/position.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "math" + "strconv" + "strings" + "time" +) + +const positionCuePrefix = "generated-position-tone:" + +// PositionCueName encodes relative position: zero is first, one is last. +func PositionCueName(position float64) string { + return positionCuePrefix + strconv.FormatFloat(position, 'g', -1, 64) +} + +func set_position(cue string) (float64, bool) { + if !strings.HasPrefix(cue, positionCuePrefix) { + return 0, false + } + position, err := strconv.ParseFloat(strings.TrimPrefix(cue, positionCuePrefix), 64) + return position, err == nil && !math.IsNaN(position) && !math.IsInf(position, 0) && position >= 0 && position <= 1 +} + +func position_tone_wav(position float64) []byte { + // Two octaves distinguish the ends of a set. Smooth the edges of this + // short cue; the service supplies the user's volume and stereo position. + return tone_wav(220*math.Pow(4, position), 60*time.Millisecond, 0.5, 5*time.Millisecond) +} diff --git a/internal/sound/position_test.go b/internal/sound/position_test.go new file mode 100644 index 0000000..cf436ea --- /dev/null +++ b/internal/sound/position_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "context" + "encoding/binary" + "log/slog" + "testing" + "time" +) + +func TestPositionTonesDecodeAndRiseInPitch(t *testing.T) { + previousCrossings := 0 + for _, position := range []float64{0, 0.5, 1} { + data, err := soundData("default", PositionCueName(position)) + if err != nil { + t.Fatal(err) + } + if binary.LittleEndian.Uint32(data[24:]) != 22050 || binary.LittleEndian.Uint16(data[22:]) != 1 || len(data) != 44+1323*2 { + t.Fatal("position tone is not 60 ms mono PCM at 22050 Hz") + } + crossings := 0 + previousSample := int16(0) + for offset := 44; offset < len(data); offset += 2 { + sample := int16(binary.LittleEndian.Uint16(data[offset:])) + if previousSample < 0 && sample >= 0 { + crossings++ + } + previousSample = sample + } + if crossings <= previousCrossings { + t.Fatalf("pitch failed to rise at %g: %d cycles", position, crossings) + } + previousCrossings = crossings + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.3, -0.5) + cancel() + if err != nil { + t.Fatal(err) + } + } + for _, value := range []string{"NaN", "+Inf", "-1", "1.1", "bad"} { + if _, err := soundData("default", positionCuePrefix+value); err == nil { + t.Fatalf("accepted position %s", value) + } + } +} + +func TestPositionSoundUsesObjectSoundPolicy(t *testing.T) { + player := &configurationPlayer{played: make(chan configuredPlayback, 8)} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.3) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := service.Stop(context.Background()); err != nil { + t.Error(err) + } + }) + cue := Cue{Name: PositionCueName(0.5), Pan: -0.5} + if !service.Enqueue(cue) { + t.Fatal("position cue was rejected") + } + select { + case playback := <-player.played: + if playback.volume != 0.3 || playback.pan != -0.5 { + t.Fatalf("position playback = %+v", playback) + } + case <-time.After(time.Second): + t.Fatal("position tone did not reach player") + } + service.mutex.Lock() + cached := len(service.cache) + service.mutex.Unlock() + if cached != 0 { + t.Fatal("continuous position values populated the sound cache") + } + for _, configuration := range []Configuration{ + {Enabled: false, Theme: "default", Volume: 0.3}, + {Enabled: true, Theme: "none", Volume: 0.3}, + {Enabled: true, Theme: "default", Volume: 0}, + } { + service.Configure(configuration) + if service.Available(cue.Name) || service.Enqueue(cue) { + t.Fatalf("position ignored policy: %+v", configuration) + } + if !service.Available(ProgressCueName(50)) { + t.Fatal("position policy affected progress tones") + } + } + service.Configure(Configuration{Enabled: true, Theme: "custom", Volume: 0.3}) + if !service.Available(cue.Name) { + t.Fatal("generated position requires a theme asset") + } +} diff --git a/internal/sound/progress.go b/internal/sound/progress.go new file mode 100644 index 0000000..fb620d7 --- /dev/null +++ b/internal/sound/progress.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "bytes" + "encoding/binary" + "math" + "strconv" + "strings" +) + +const progressCuePrefix = "generated-progress-tone:" + +func ProgressCueName(percentage float64) string { + return progressCuePrefix + strconv.FormatFloat(percentage, 'g', -1, 64) +} + +func progress_percentage(cue string) (float64, bool) { + if !strings.HasPrefix(cue, progressCuePrefix) { + return 0, false + } + percentage, err := strconv.ParseFloat(strings.TrimPrefix(cue, progressCuePrefix), 64) + return percentage, err == nil && !math.IsNaN(percentage) && !math.IsInf(percentage, 0) && percentage >= 0 && percentage <= 100 +} + +// Match NVDA's default ProgressBar event_valueChange and generateBeep: +// 110 * 2^(percentage/25) Hz, 40 ms padded to the next integer cycle, +// 44100 Hz stereo PCM, and a sine clipped after multiplying by two. +// NVDA passes frequency through a C float and defaults each channel to 50%. +// Reference: nvaccess/nvda a4a89720ccbf3973a0e1e50a76dd8bff38db3d15, +// source/NVDAObjects/behaviors.py and nvdaHelper/local/beeps.cpp. +func progress_tone_wav(percentage float64) []byte { + const sampleRate = 44100 + frequency := float64(float32(110 * math.Pow(2, percentage/25))) + // sampleRate/hz is evaluated as a float in NVDA's C++ generator. + cycleLength := float64(float32(sampleRate) / float32(frequency)) + samplesPerCycle := int(cycleLength) + sampleCount := int(0.040 / (1.0 / sampleRate)) + sampleCount += samplesPerCycle - sampleCount%samplesPerCycle + dataSize := sampleCount * 4 + buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize)) + buffer.WriteString("RIFF") + _ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize)) + buffer.WriteString("WAVEfmt ") + _ = binary.Write(buffer, binary.LittleEndian, uint32(16)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(1)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(2)) + _ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate)) + _ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate*4)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(4)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(16)) + buffer.WriteString("data") + _ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize)) + sinFrequency := 2 * math.Pi / cycleLength + for sample := 0; sample < sampleCount; sample++ { + value := int16(min(1.0, max(-1.0, math.Sin(float64(sample%sampleRate)*sinFrequency)*2)) * 7000) + _ = binary.Write(buffer, binary.LittleEndian, value) + _ = binary.Write(buffer, binary.LittleEndian, value) + } + return buffer.Bytes() +} diff --git a/internal/sound/progress_test.go b/internal/sound/progress_test.go new file mode 100644 index 0000000..4a93c7d --- /dev/null +++ b/internal/sound/progress_test.go @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "log/slog" + "testing" + "time" +) + +func TestProgressAudioMatchesNVDAGenerator(t *testing.T) { + // PCM digests from the unmodified generateBeep in the reference commit + // named in progress.go, compiled locally with a small output-only harness. + all := sha256.New() + for percentage := 0; percentage <= 100; percentage++ { + data := progress_tone_wav(float64(percentage)) + if binary.LittleEndian.Uint32(data[24:]) != 44100 || binary.LittleEndian.Uint16(data[22:]) != 2 { + t.Fatal("progress tone is not 44100 Hz stereo") + } + all.Write(data[44:]) + } + if got := fmt.Sprintf("%x", all.Sum(nil)); got != "ef56e46be365269af544e22d94eb1de7d171c5946d1dd5b168cc7e570c0a159f" { + t.Fatalf("0 through 100 percent PCM differs from NVDA: %s", got) + } + for percentage, digest := range map[float64]string{ + 12.5: "4fc87ad5310038ed281a4516e9431d5dc247b7ce75662e4fe51cb4d2ab616a36", + 50.25: "6b4a9a7d254686b2fb52ead07763ff596ccdcbdd30d5e9d4ce956df13cc1bdf5", + } { + if got := fmt.Sprintf("%x", sha256.Sum256(progress_tone_wav(percentage)[44:])); got != digest { + t.Fatalf("%g percent PCM differs from NVDA: %s", percentage, got) + } + } +} + +type progressPlayback struct { + context context.Context + data []byte +} + +type progressTestPlayer struct { + started chan progressPlayback + release chan struct{} +} + +func (player *progressTestPlayer) Play(ctx context.Context, data []byte, _, _ float64) error { + player.started <- progressPlayback{context: ctx, data: data} + select { + case <-ctx.Done(): + return ctx.Err() + case <-player.release: + return nil + } +} + +func new_progress_test_service(t *testing.T) (*Service, *progressTestPlayer) { + t.Helper() + player := &progressTestPlayer{started: make(chan progressPlayback, 16), release: make(chan struct{}, 16)} + service := New(slog.New(slog.DiscardHandler), player, "none", 0) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Error(err) + } + }) + return service, player +} + +func next_progress_playback(t *testing.T, player *progressTestPlayer) progressPlayback { + t.Helper() + select { + case playback := <-player.started: + return playback + case <-time.After(time.Second): + t.Fatal("sound did not start") + return progressPlayback{} + } +} + +func TestRapidProgressFinishesActiveToneAndPlaysLatestValue(t *testing.T) { + service, player := new_progress_test_service(t) + service.Enqueue(Cue{Name: ProgressCueName(0)}) + first := next_progress_playback(t, player) + for percentage := 1; percentage <= 100; percentage++ { + if !service.EnqueueBatch([]Cue{{Name: ProgressCueName(float64(percentage))}}) { + t.Fatal("rapid progress update rejected") + } + } + if err := first.context.Err(); err != nil { + t.Fatalf("rapid updates canceled the active progress tone: %v", err) + } + player.release <- struct{}{} + latest := next_progress_playback(t, player) + if !bytes.Equal(latest.data, progress_tone_wav(100)) { + t.Fatal("played an obsolete percentage instead of the latest value") + } +} + +func TestProgressPreservesActiveAndPendingRoleSounds(t *testing.T) { + service, player := new_progress_test_service(t) + service.mutex.Lock() + service.enabled, service.theme, service.volume = true, "default", 0.5 + service.available = map[string]bool{"button": true, "link": true} + service.mutex.Unlock() + service.EnqueueBatch([]Cue{{Name: "button"}, {Name: "link"}}) + first := next_progress_playback(t, player) + service.Enqueue(Cue{Name: ProgressCueName(100)}) + if err := first.context.Err(); err != nil { + t.Fatalf("progress interrupted a role sound: %v", err) + } + player.release <- struct{}{} + second := next_progress_playback(t, player) + want, err := load_sound("default", "link") + if err != nil || !bytes.Equal(second.data, want) { + t.Fatal("progress displaced a pending role sound") + } + player.release <- struct{}{} + if got := next_progress_playback(t, player); !bytes.Equal(got.data, progress_tone_wav(100)) { + t.Fatal("latest progress did not follow the role sounds") + } +} + +func TestProgressCancellationDiscardsPendingValue(t *testing.T) { + for _, action := range []string{"presentation", "configure", "stop"} { + t.Run(action, func(t *testing.T) { + service, player := new_progress_test_service(t) + service.Enqueue(Cue{Name: ProgressCueName(0)}) + first := next_progress_playback(t, player) + service.Enqueue(Cue{Name: ProgressCueName(100)}) + switch action { + case "presentation": + service.mutex.Lock() + service.enabled, service.available["button"] = true, true + service.theme = "default" + service.mutex.Unlock() + service.Enqueue(Cue{Name: "button"}) + case "configure": + service.Configure(Configuration{Theme: "none"}) + case "stop": + if err := service.Stop(context.Background()); err != nil { + t.Fatal(err) + } + } + if first.context.Err() == nil { + t.Fatal("active progress was not canceled") + } + service.mutex.Lock() + pending := service.pendingProgress + service.mutex.Unlock() + if pending != nil { + t.Fatal("obsolete progress remained pending") + } + }) + } +} + +func TestProgressSoundIsIndependentOfRoleSoundSettings(t *testing.T) { + service := New(nil, nil, "none", 0) + service.Configure(Configuration{Enabled: false, Theme: "none", Volume: 0}) + service.running = true + if service.Available("button") || !service.Available(ProgressCueName(50.25)) { + t.Fatal("progress tone availability follows role-sound policy") + } + if !service.Enqueue(Cue{Name: ProgressCueName(50.25)}) { + t.Fatal("progress tone rejected with role sounds disabled") + } + for _, value := range []string{"NaN", "+Inf", "-1", "101", "bad"} { + if service.Available(progressCuePrefix + value) { + t.Fatalf("invalid percentage %s available", value) + } + if _, err := soundData("none", progressCuePrefix+value); err == nil { + t.Fatalf("invalid percentage %s decoded", value) + } + } +} diff --git a/internal/sound/service.go b/internal/sound/service.go new file mode 100644 index 0000000..51cf569 --- /dev/null +++ b/internal/sound/service.go @@ -0,0 +1,432 @@ +package sound + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + bundled "git.stormux.org/storm/cthulhu/assets/sounds" +) + +const queueCapacity = 8 +const maxSoundBytes = 4 * 1024 * 1024 + +type Service struct { + stopMutex sync.Mutex + player Player + logger *slog.Logger + theme string + volume float64 + queue chan queuedCue + done chan struct{} + cancel context.CancelFunc + mutex sync.Mutex + running bool + enabled bool + stopPending bool + dropped atomic.Uint64 + cache map[string][]byte + available map[string]bool + generation uint64 + active context.CancelFunc + activeID uint64 + // The sound worker owns one pending progress value under mutex. Updates + // replace this slot without canceling playback or the role-sound queue. + pendingProgress *queuedCue + progressWake chan struct{} +} + +type queuedCue struct { + Cue + generation uint64 +} + +func New(logger *slog.Logger, player Player, theme string, volume float64) *Service { + return &Service{ + logger: logger, player: player, theme: theme, volume: volume, enabled: true, + queue: make(chan queuedCue, queueCapacity), cache: make(map[string][]byte), available: cueAvailability(theme), + progressWake: make(chan struct{}, 1), + } +} + +// Cue is immutable presentation data for one sound. Pan ranges from -1 (left) +// through 0 (center) to 1 (right). Cue names never encode placement metadata. +type Cue struct { + Name string + Pan float64 +} + +type Configuration struct { + Enabled bool + Sink string + Theme string + Volume float64 +} + +// Configure atomically changes playback without restarting the owned worker. +// Active and pending cues from the old policy are canceled. +func (service *Service) Configure(configuration Configuration) { + available := cueAvailability(configuration.Theme) + service.mutex.Lock() + defer service.mutex.Unlock() + service.generation++ + if service.active != nil { + service.active() + service.active = nil + } + service.discardPending() + service.enabled = configuration.Enabled && configuration.Theme != "none" && configuration.Volume > 0 + service.theme = configuration.Theme + service.volume = configuration.Volume + service.available = available + if player, ok := service.player.(NativePlayer); ok { + player.Sink = configuration.Sink + service.player = player + } +} + +// Available reports whether current policy permits a named cue and the +// configured theme contains it. It performs no filesystem I/O. +func (service *Service) Available(name string) bool { + service.mutex.Lock() + defer service.mutex.Unlock() + return service.running && service.cue_available(name) +} + +func (service *Service) Start(ctx context.Context) error { + service.mutex.Lock() + defer service.mutex.Unlock() + if service.done != nil { + return errors.New("sound service already started") + } + workerContext, cancel := context.WithCancel(ctx) + service.cancel = cancel + service.done = make(chan struct{}) + service.running = true + service.stopPending = false + go service.run(workerContext, service.done) + return nil +} + +func (service *Service) Enqueue(cue Cue) bool { + return service.EnqueueBatch([]Cue{cue}) +} + +// EnqueueBatch replaces the previous presentation, then plays its cues in +// order. Python Cthulhu interrupts on the first cue and appends later cues. +// A rejected batch leaves the current presentation alone so sound-only callers +// can retain speech when its cues are unavailable. A single progress cue instead +// replaces the pending percentage and lets the active sound finish. +func (service *Service) EnqueueBatch(cues []Cue) bool { + if len(cues) == 0 { + return true + } + service.mutex.Lock() + defer service.mutex.Unlock() + if !service.running { + return false + } + if len(cues) > cap(service.queue) { + service.dropped.Add(uint64(len(cues))) + return false + } + for _, cue := range cues { + if !service.cue_available(cue.Name) { + return false + } + } + if len(cues) == 1 { + if _, progress := progress_percentage(cues[0].Name); progress { + cue := cues[0] + cue.Pan = normalizedPan(cue.Pan) + service.pendingProgress = &queuedCue{Cue: cue, generation: service.generation} + select { + case service.progressWake <- struct{}{}: + default: + } + return true + } + } + service.generation++ + if service.active != nil { + service.active() + } + service.discardPending() + for _, cue := range cues { + cue.Pan = normalizedPan(cue.Pan) + service.queue <- queuedCue{Cue: cue, generation: service.generation} + } + return true +} + +// The caller holds mutex, excluding producers. The worker can still receive +// before acquiring mutex, so checking len and then receiving can deadlock. +func (service *Service) discardPending() { + service.pendingProgress = nil + for { + select { + case <-service.queue: + default: + return + } + } +} + +func (service *Service) run(ctx context.Context, done chan struct{}) { + defer close(done) + for { + cue, ok := service.next_cue(ctx) + if !ok { + return + } + if count := service.dropped.Swap(0); count != 0 { + service.logger.Warn("sound queue overflow", "dropped", count) + } + if err := service.playQueued(ctx, cue); err != nil && ctx.Err() == nil { + service.logger.Warn("play sound", "cue", cue.Name, "error", err) + } + } +} + +// Preserve the order of ordinary presentation cues before taking the newest +// progress value. progressWake is only a notification; mutex owns the slot. +func (service *Service) next_cue(ctx context.Context) (queuedCue, bool) { + for { + if ctx.Err() != nil { + return queuedCue{}, false + } + service.mutex.Lock() + select { + case cue := <-service.queue: + service.mutex.Unlock() + return cue, true + default: + } + pending := service.pendingProgress + service.pendingProgress = nil + service.mutex.Unlock() + if pending != nil { + return *pending, true + } + select { + case <-ctx.Done(): + return queuedCue{}, false + case cue := <-service.queue: + return cue, true + case <-service.progressWake: + } + } +} + +func (service *Service) playQueued(ctx context.Context, cue queuedCue) error { + service.mutex.Lock() + if cue.generation != service.generation || !service.running || !service.cue_available(cue.Name) { + service.mutex.Unlock() + return nil + } + player, theme, volume := service.player, service.theme, service.volume + if _, valid := progress_percentage(cue.Name); valid { + volume = 1 + } + cacheKey := theme + "\x00" + cue.Name + data, exists := service.cache[cacheKey] + service.activeID++ + activeID := service.activeID + playContext, cancel := context.WithTimeout(ctx, 3*time.Second) + service.active = cancel + service.mutex.Unlock() + defer func() { + cancel() + service.mutex.Lock() + if service.activeID == activeID { + service.active = nil + } + service.mutex.Unlock() + }() + if !exists { + var err error + data, err = soundData(theme, cue.Name) + if err != nil { + return err + } + service.mutex.Lock() + if !generated_tone(cue.Name) { + service.cache[cacheKey] = data + } + service.mutex.Unlock() + } + return player.Play(playContext, data, volume, normalizedPan(cue.Pan)) +} + +func (service *Service) play(ctx context.Context, cue Cue) error { + service.mutex.Lock() + player, theme, volume := service.player, service.theme, service.volume + if _, valid := progress_percentage(cue.Name); valid { + volume = 1 + } + cacheKey := theme + "\x00" + cue.Name + data, exists := service.cache[cacheKey] + service.mutex.Unlock() + if !exists { + var err error + data, err = soundData(theme, cue.Name) + if err != nil { + return err + } + service.mutex.Lock() + if !generated_tone(cue.Name) { + service.cache[cacheKey] = data + } + service.mutex.Unlock() + } + playContext, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return player.Play(playContext, data, volume, normalizedPan(cue.Pan)) +} + +func normalizedPan(pan float64) float64 { + if math.IsNaN(pan) || math.IsInf(pan, 0) || pan < -1 || pan > 1 { + return 0 + } + return pan +} + +var namedCues = []string{ + "start", "stop", "button", "checkbox_checked", "checkbox_mixed", "checkbox_unchecked", + "autocomplete", "combobox", "link", "link_visited", "radiobutton_checked", "radiobutton_unchecked", + "togglebutton_checked", "togglebutton_unchecked", "switch_on", "switch_off", "browse_mode", "focus_mode", +} + +func cueAvailability(theme string) map[string]bool { + available := make(map[string]bool, len(namedCues)) + for _, name := range namedCues { + if data, err := load_sound(theme, name); err == nil && len(data) <= maxSoundBytes { + available[name] = true + } + } + return available +} + +// Called under the service mutex. Progress output is controlled by its own +// output mode, independent of role-sound enablement, theme and volume. +func (service *Service) cue_available(name string) bool { + if _, valid := indentation_frequency(name); valid { + return service.enabled && service.theme != "none" && service.volume > 0 + } + if _, valid := progress_percentage(name); valid { + return true + } + if _, valid := set_position(name); valid { + return service.enabled && service.theme != "none" && service.volume > 0 + } + return service.enabled && service.available[name] +} + +func generated_tone(name string) bool { + _, indentation := indentation_frequency(name) + _, progress := progress_percentage(name) + _, position := set_position(name) + return progress || position || indentation +} + +func soundData(theme, cue string) ([]byte, error) { + if strings.HasPrefix(cue, indentationCuePrefix) { + frequency, valid := indentation_frequency(cue) + if !valid { + return nil, errors.New("invalid generated indentation tone") + } + return indentation_tone_wav(frequency), nil + } + if strings.HasPrefix(cue, positionCuePrefix) { + position, valid := set_position(cue) + if !valid { + return nil, errors.New("invalid generated position tone") + } + return position_tone_wav(position), nil + } + if strings.HasPrefix(cue, progressCuePrefix) { + percentage, valid := progress_percentage(cue) + if !valid { + return nil, errors.New("invalid generated progress tone") + } + return progress_tone_wav(percentage), nil + } + return load_sound(theme, cue) +} + +// Stop cancels any active cue, discards pending cues, and plays the stop cue +// with the caller's shutdown deadline. The sound service never owns speech. +func (service *Service) Stop(ctx context.Context) error { + service.stopMutex.Lock() + defer service.stopMutex.Unlock() + service.mutex.Lock() + if service.done == nil { + service.mutex.Unlock() + return nil + } + playStopCue := (service.running && service.enabled && service.available["stop"]) || service.stopPending + service.running = false + service.discardPending() + service.stopPending = true + done := service.done + if done != nil { + service.cancel() + } + service.mutex.Unlock() + if done != nil { + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + } + if playStopCue { + if err := service.play(ctx, Cue{Name: "stop"}); err != nil { + return err + } + } + service.mutex.Lock() + service.stopPending = false + service.done = nil + service.mutex.Unlock() + return nil +} + +func load_sound(theme, cue string) ([]byte, error) { + if filepath.Base(theme) != theme || theme == "." || theme == ".." || filepath.Base(cue) != cue || cue == "." || cue == ".." { + return nil, errors.New("invalid sound theme or cue name") + } + if theme == "default" { + return bundled.Default.ReadFile("default/" + cue + ".wav") + } + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + dataHome = filepath.Join(home, ".local", "share") + } + file, err := os.Open(filepath.Join(dataHome, "cthulhu", "sounds", theme, cue+".wav")) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxSoundBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxSoundBytes { + return nil, fmt.Errorf("sound exceeds %d bytes", maxSoundBytes) + } + return data, nil +} diff --git a/internal/sound/service_test.go b/internal/sound/service_test.go new file mode 100644 index 0000000..e5595e1 --- /dev/null +++ b/internal/sound/service_test.go @@ -0,0 +1,628 @@ +package sound + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestBundledSoundsDecodeInProcess(t *testing.T) { + if !cueAvailability("default")["autocomplete"] { + t.Fatal("default theme does not offer the autocomplete cue") + } + for _, cue := range []string{"start", "stop", "autocomplete", "button", "checkbox_checked", "checkbox_mixed", "checkbox_unchecked", "combobox", "link", "link_visited", "radiobutton_checked", "radiobutton_unchecked", "togglebutton_checked", "togglebutton_unchecked", "switch_on", "switch_off", "browse_mode", "focus_mode"} { + t.Run(cue, func(t *testing.T) { + data, err := load_sound("default", cue) + if err != nil { + t.Fatal(err) + } + // The first GStreamer initialization can exceed one second on a + // cold process even when the cue itself is short. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGeneratedProgressSoundsDecodeInProcess(t *testing.T) { + for _, percentage := range []int{0, 50, 99, 100} { + data, err := soundData("default", fmt.Sprintf("generated-progress-tone:%d", percentage)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0) + cancel() + if err != nil { + t.Fatalf("percentage %d: %v", percentage, err) + } + } + if _, err := soundData("default", "generated-progress-tone:101"); err == nil { + t.Fatal("accepted invalid generated progress tone") + } +} + +func TestBundledSoundPanDecodes(t *testing.T) { + data, err := load_sound("default", "button") + if err != nil { + t.Fatal(err) + } + for _, pan := range []float64{-1, 0, 0.35, 1} { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, pan) + cancel() + if err != nil { + t.Fatalf("pan %v: %v", pan, err) + } + } +} + +func TestThemePathsAndSizeAreBounded(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + for _, name := range []string{"..", "../other", "/tmp/other"} { + if _, err := load_sound(name, "start"); err == nil { + t.Fatalf("accepted theme %q", name) + } + if _, err := load_sound("default", name); err == nil { + t.Fatalf("accepted cue %q", name) + } + } + path := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "sounds", "custom") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "start.wav"), []byte("custom"), 0o600); err != nil { + t.Fatal(err) + } + data, err := load_sound("custom", "start") + if err != nil || string(data) != "custom" { + t.Fatalf("custom theme = %q, %v", data, err) + } + if err := os.WriteFile(filepath.Join(path, "start.wav"), make([]byte, maxSoundBytes+1), 0o600); err != nil { + t.Fatal(err) + } + if _, err := load_sound("custom", "start"); err == nil { + t.Fatal("accepted oversized sound") + } +} + +type blockingPlayer struct { + started chan struct{} + calls int +} + +func TestQueueReplacementDoesNotBlockConcurrentConsumer(t *testing.T) { + service := New(slog.New(slog.DiscardHandler), nil, "default", 0.5) + service.running = true + service.enabled = true + service.available = map[string]bool{"button": true} + ctx, cancel := context.WithCancel(context.Background()) + consumerDone := make(chan struct{}) + go func() { + defer close(consumerDone) + for { + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-service.queue: + // The runtime receives before taking the playback mutex. + service.mutex.Lock() + service.mutex.Unlock() + } + } + }() + done := make(chan struct{}) + go func() { + defer close(done) + for range 100000 { + if ctx.Err() != nil { + return + } + service.Enqueue(Cue{Name: "button"}) + } + }() + select { + case <-done: + cancel() + <-consumerDone + case <-time.After(5 * time.Second): + cancel() + // Release a blocked drain so both test goroutines can be joined. + service.queue <- queuedCue{} + <-done + <-consumerDone + t.Fatal("queue replacement blocked while the consumer waited for the mutex") + } +} + +type interruptPlayer struct { + started chan struct{} + canceled chan struct{} + played chan []byte + once sync.Once +} + +func (player *interruptPlayer) Play(ctx context.Context, data []byte, _, _ float64) error { + first := false + player.once.Do(func() { first = true; close(player.started) }) + if first { + <-ctx.Done() + close(player.canceled) + return ctx.Err() + } + player.played <- data + return nil +} + +func TestNewPresentationInterruptsOldCueAndDiscardsPendingCues(t *testing.T) { + player := &interruptPlayer{started: make(chan struct{}), canceled: make(chan struct{}), played: make(chan []byte, 2)} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + if !service.Enqueue(Cue{Name: "button"}) { + t.Fatal("initial cue rejected") + } + select { + case <-player.started: + case <-time.After(time.Second): + t.Fatal("initial cue did not start") + } + for range 20 { + if !service.Enqueue(Cue{Name: "link"}) { + t.Fatal("new link cue rejected") + } + } + select { + case <-player.canceled: + case <-time.After(time.Second): + t.Fatal("initial cue was not interrupted") + } + want, err := load_sound("default", "link") + if err != nil { + t.Fatal(err) + } + select { + case got := <-player.played: + if !bytes.Equal(got, want) { + t.Fatal("played stale cue instead of latest link") + } + case <-time.After(time.Second): + t.Fatal("latest link cue did not play") + } + if len(service.queue) != 0 { + t.Fatal("stale link cues remained queued") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +type configuredPlayback struct { + volume float64 + pan float64 +} + +type configurationPlayer struct { + played chan configuredPlayback +} + +type reconfigurePlayer struct { + started chan struct{} + release chan struct{} + canceled chan struct{} + played chan struct{} + once sync.Once +} + +func (player *reconfigurePlayer) Play(ctx context.Context, _ []byte, _, _ float64) error { + first := false + player.once.Do(func() { + first = true + close(player.started) + }) + if first { + select { + case <-player.release: + case <-ctx.Done(): + close(player.canceled) + return ctx.Err() + } + return nil + } + player.played <- struct{}{} + return nil +} + +func (player *configurationPlayer) Play(_ context.Context, _ []byte, volume, pan float64) error { + player.played <- configuredPlayback{volume: volume, pan: pan} + return nil +} + +func TestCuePanIsValidatedAndPassedToPlayer(t *testing.T) { + player := &configurationPlayer{played: make(chan configuredPlayback, 2)} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + service.Enqueue(Cue{Name: "button", Pan: 0.5}) + select { + case playback := <-player.played: + if playback.pan != 0.5 { + t.Fatalf("pan = %v, want 0.5", playback.pan) + } + case <-time.After(time.Second): + t.Fatal("panned cue was not played") + } + service.Enqueue(Cue{Name: "button", Pan: 2}) + select { + case playback := <-player.played: + if playback.pan != 0 { + t.Fatalf("invalid pan = %v, want centered", playback.pan) + } + case <-time.After(time.Second): + t.Fatal("invalid-pan cue was not played") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +func TestCustomThemeCueSupportsPan(t *testing.T) { + dataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + path := filepath.Join(dataHome, "cthulhu", "sounds", "custom") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("custom wav"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "stop.wav"), []byte("custom stop"), 0o600); err != nil { + t.Fatal(err) + } + player := &configurationPlayer{played: make(chan configuredPlayback, 1)} + service := New(slog.New(slog.DiscardHandler), player, "custom", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + if !service.Available("button") { + t.Fatal("custom theme cue was not available") + } + service.Enqueue(Cue{Name: "button", Pan: -1}) + select { + case playback := <-player.played: + if playback.pan != -1 { + t.Fatalf("custom cue pan = %v, want -1", playback.pan) + } + case <-time.After(time.Second): + t.Fatal("custom cue was not played") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +func TestConfigureAppliesEnableAndVolumeToSubsequentCues(t *testing.T) { + player := &configurationPlayer{played: make(chan configuredPlayback, 2)} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + service.Configure(Configuration{Enabled: false, Sink: "auto", Theme: "default", Volume: 0.5}) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + service.Enqueue(Cue{Name: "button"}) + if len(service.queue) != 0 { + t.Fatal("disabled service queued a cue") + } + service.Configure(Configuration{Enabled: true, Sink: "pulse", Theme: "default", Volume: 0.8}) + service.Enqueue(Cue{Name: "button"}) + select { + case playback := <-player.played: + if playback.volume != 0.8 { + t.Fatalf("volume = %v, want 0.8", playback.volume) + } + case <-time.After(time.Second): + t.Fatal("configured cue was not played") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +func TestConfigureCancelsActiveOldPolicyCue(t *testing.T) { + player := &reconfigurePlayer{started: make(chan struct{}), release: make(chan struct{}), canceled: make(chan struct{}), played: make(chan struct{}, 1)} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + if !service.Enqueue(Cue{Name: "button"}) { + t.Fatal("first cue was not admitted") + } + select { + case <-player.started: + case <-time.After(time.Second): + t.Fatal("first cue did not start") + } + service.Configure(Configuration{Enabled: false, Theme: "default", Volume: 0.5}) + select { + case <-player.canceled: + case <-time.After(time.Second): + t.Fatal("active old-policy cue was not canceled") + } + select { + case <-player.played: + t.Fatal("old-policy pending cue played after reconfiguration") + case <-time.After(30 * time.Millisecond): + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +func TestCueBatchAdmissionIsAtomic(t *testing.T) { + player := &blockingPlayer{started: make(chan struct{})} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + if !service.Enqueue(Cue{Name: "start"}) { + t.Fatal("blocking cue was not admitted") + } + select { + case <-player.started: + case <-time.After(time.Second): + t.Fatal("sound worker did not start") + } + batch := make([]Cue, queueCapacity+1) + for index := range batch { + batch[index] = Cue{Name: "button"} + } + if service.EnqueueBatch(batch) { + t.Fatal("oversized presentation batch was admitted") + } + if len(service.queue) != 0 { + t.Fatal("part of rejected presentation batch was queued") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +func TestPartialCustomThemeStopsWithoutShutdownCue(t *testing.T) { + dataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + path := filepath.Join(dataHome, "cthulhu", "sounds", "partial") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("button"), 0o600); err != nil { + t.Fatal(err) + } + service := New(slog.New(slog.DiscardHandler), &configurationPlayer{played: make(chan configuredPlayback, 1)}, "partial", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } +} + +func (player *blockingPlayer) Play(ctx context.Context, _ []byte, _, _ float64) error { + player.calls++ + if player.calls == 1 { + close(player.started) + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func TestSoundWorkerBoundsQueueAndStopsActiveCue(t *testing.T) { + player := &blockingPlayer{started: make(chan struct{})} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + service.Enqueue(Cue{Name: "start"}) + select { + case <-player.started: + case <-time.After(time.Second): + t.Fatal("sound worker did not start") + } + batch := make([]Cue, queueCapacity+1) + for index := range batch { + batch[index] = Cue{Name: "button"} + } + service.EnqueueBatch(batch) + if service.dropped.Load() == 0 { + t.Fatal("overflow not counted") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } + if player.calls != 2 { + t.Fatalf("played %d cues; expected active cue then stop", player.calls) + } + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } + if player.calls != 2 { + t.Fatal("repeated stop played another cue") + } +} + +func TestStopPlaysShutdownCueAfterOversizedPresentation(t *testing.T) { + stopData, err := load_sound("default", "stop") + if err != nil { + t.Fatal(err) + } + player := &queueBlockingPlayer{stopData: stopData, entered: make(chan struct{})} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + service.Enqueue(Cue{Name: "button"}) + select { + case <-player.entered: + case <-time.After(time.Second): + t.Fatal("sound worker did not start") + } + batch := make([]Cue, queueCapacity+1) + for index := range batch { + batch[index] = Cue{Name: "button"} + } + service.EnqueueBatch(batch) + if service.dropped.Load() == 0 { + t.Fatal("oversized presentation was not dropped") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } + if player.stopCalls != 1 { + t.Fatalf("played %d stop cues; expected 1", player.stopCalls) + } + if err := service.Stop(ctx); err != nil { + t.Fatal(err) + } + if player.stopCalls != 1 { + t.Fatalf("repeated stop played %d stop cues; expected no more", player.stopCalls) + } +} + +func TestStopTimeoutPreservesShutdownCueForRetry(t *testing.T) { + stopData, err := load_sound("default", "stop") + if err != nil { + t.Fatal(err) + } + player := &stagedStopPlayer{stopData: stopData, entered: make(chan struct{}), release: make(chan struct{})} + service := New(slog.New(slog.DiscardHandler), player, "default", 0.5) + if err := service.Start(context.Background()); err != nil { + t.Fatal(err) + } + service.Enqueue(Cue{Name: "button"}) + select { + case <-player.entered: + case <-time.After(time.Second): + t.Fatal("sound worker did not start") + } + + timeoutContext, cancelTimeout := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancelTimeout() + if err := service.Stop(timeoutContext); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timed-out Stop error = %v; want deadline exceeded", err) + } + close(player.release) + + retryContext, cancelRetry := context.WithTimeout(context.Background(), time.Second) + defer cancelRetry() + if err := service.Stop(retryContext); err != nil { + t.Fatal(err) + } + if player.stopCalls != 1 { + t.Fatalf("played %d stop cues; expected 1 after retry", player.stopCalls) + } +} + +type stopCountingPlayer struct { + stopData []byte + stopCalls int +} + +type queueBlockingPlayer struct { + stopData []byte + entered chan struct{} + enterOnce sync.Once + stopCalls int +} + +func (player *queueBlockingPlayer) Play(ctx context.Context, data []byte, _, _ float64) error { + if bytes.Equal(data, player.stopData) { + player.stopCalls++ + return nil + } + player.enterOnce.Do(func() { close(player.entered) }) + <-ctx.Done() + return ctx.Err() +} + +func (player *stopCountingPlayer) Play(_ context.Context, data []byte, _, _ float64) error { + if bytes.Equal(data, player.stopData) { + player.stopCalls++ + } + return nil +} + +type stagedStopPlayer struct { + stopData []byte + entered chan struct{} + release chan struct{} + stopCalls int +} + +func (player *stagedStopPlayer) Play(_ context.Context, data []byte, _, _ float64) error { + if bytes.Equal(data, player.stopData) { + player.stopCalls++ + return nil + } + close(player.entered) + <-player.release + return nil +} + +func TestCancelledNativeSoundDoesNotStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := (NativePlayer{Sink: "test"}).Play(ctx, []byte("invalid"), 0.5, 0); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestLiveSound(t *testing.T) { + if os.Getenv("CTHULHU_LIVE_SOUND") != "1" { + t.Skip("explicit live audio test") + } + for _, cue := range []string{"start", "stop"} { + data, err := load_sound("default", cue) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = (NativePlayer{Sink: "auto"}).Play(ctx, data, 0.5, 0) + cancel() + if err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/sound/speech_beep.go b/internal/sound/speech_beep.go new file mode 100644 index 0000000..dd1fc1e --- /dev/null +++ b/internal/sound/speech_beep.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "context" + "time" +) + +var speechBeepWAV = tone_wav(440, 40*time.Millisecond, 0.35, 5*time.Millisecond) + +// SpeechBeep replaces an utterance, independently of role-sound enablement or +// theme availability. The speech worker owns its ordering and cancellation; +// sound preferences still supply the output sink and volume. +func (service *Service) SpeechBeep(ctx context.Context) error { + service.mutex.Lock() + player, volume := service.player, service.volume + service.mutex.Unlock() + beepContext, cancelBeep := context.WithTimeout(ctx, 3*time.Second) + defer cancelBeep() + return player.Play(beepContext, speechBeepWAV, volume, 0) +} diff --git a/internal/sound/speech_beep_test.go b/internal/sound/speech_beep_test.go new file mode 100644 index 0000000..a813f41 --- /dev/null +++ b/internal/sound/speech_beep_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" +) + +func TestSpeechBeepDecodesWithRoleSoundsDisabled(t *testing.T) { + service := New(slog.New(slog.DiscardHandler), NativePlayer{Sink: "auto"}, "default", 0.5) + service.Configure(Configuration{Enabled: false, Theme: "none", Sink: "test", Volume: 0.4}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := service.SpeechBeep(ctx); err != nil { + t.Fatal(err) + } + canceled, cancelBeep := context.WithCancel(ctx) + cancelBeep() + if err := service.SpeechBeep(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled beep = %v", err) + } +} diff --git a/internal/sound/tone.go b/internal/sound/tone.go new file mode 100644 index 0000000..60096a5 --- /dev/null +++ b/internal/sound/tone.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "bytes" + "encoding/binary" + "math" + "time" +) + +// tone_wav generates mono, 16-bit PCM at 22050 Hz for the existing Player. +// Callers supply a positive frequency/duration, amplitude in [0, 1], and a +// nonnegative edge ramp. A zero ramp preserves an unsmoothed sine tone. +// Playback volume, pan, queuing, and cancellation belong to the sound service. +func tone_wav(frequency float64, duration time.Duration, amplitude float64, ramp time.Duration) []byte { + const sampleRate = 22050 + sampleCount := int(float64(sampleRate) * duration.Seconds()) + rampSamples := int(float64(sampleRate) * ramp.Seconds()) + dataSize := sampleCount * 2 + buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize)) + buffer.WriteString("RIFF") + _ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize)) + buffer.WriteString("WAVEfmt ") + _ = binary.Write(buffer, binary.LittleEndian, uint32(16)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(1)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(1)) + _ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate)) + _ = binary.Write(buffer, binary.LittleEndian, uint32(sampleRate*2)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(2)) + _ = binary.Write(buffer, binary.LittleEndian, uint16(16)) + buffer.WriteString("data") + _ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize)) + for sample := 0; sample < sampleCount; sample++ { + envelope := 1.0 + if rampSamples > 0 { + envelope = min(1.0, float64(min(sample, sampleCount-1-sample))/float64(rampSamples)) + } + value := int16(math.Sin(2*math.Pi*frequency*float64(sample)/sampleRate) * amplitude * envelope * math.MaxInt16) + _ = binary.Write(buffer, binary.LittleEndian, value) + } + return buffer.Bytes() +} diff --git a/internal/sound/tone_test.go b/internal/sound/tone_test.go new file mode 100644 index 0000000..a504c32 --- /dev/null +++ b/internal/sound/tone_test.go @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package sound + +import ( + "crypto/sha256" + "fmt" + "testing" +) + +func TestTonePreservesSpeechAudio(t *testing.T) { + // Preserve the accepted speech beep while progress audio follows NVDA. + const speechDigest = "2f0184d3deefce36901b9a940d5caefde08e15058898d20c398ad10ad1ce1b94" + if got := fmt.Sprintf("%x", sha256.Sum256(speechBeepWAV)); got != speechDigest { + t.Fatalf("speech beep audio changed: %s", got) + } +} diff --git a/internal/speech/address.go b/internal/speech/address.go new file mode 100644 index 0000000..5157b03 --- /dev/null +++ b/internal/speech/address.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" +) + +type serverAddress struct { + network string + address string +} + +// DefaultSocketAddress follows the default used by the Python speechd client. +func DefaultSocketAddress() string { + runtimeDirectory := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDirectory == "" { + runtimeDirectory = os.Getenv("XDG_CACHE_HOME") + } + if runtimeDirectory == "" { + cacheDirectory, err := os.UserCacheDir() + if err == nil { + runtimeDirectory = cacheDirectory + } + } + return filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock") +} + +// resolveServerAddress gives an explicit CLI socket priority over speechd's +// standard address variables. SPEECHD_SOCKET is the older Unix-only setting. +func resolveServerAddress(socket string) (serverAddress, error) { + if socket != "" { + return serverAddress{network: "unix", address: socket}, nil + } + if address := os.Getenv("SPEECHD_ADDRESS"); address != "" { + return parseServerAddress(address) + } + if socket := os.Getenv("SPEECHD_SOCKET"); socket != "" { + return serverAddress{network: "unix", address: socket}, nil + } + return serverAddress{network: "unix", address: DefaultSocketAddress()}, nil +} + +func parseServerAddress(address string) (serverAddress, error) { + method, value, _ := strings.Cut(address, ":") + switch method { + case "unix_socket": + if value == "" { + value = DefaultSocketAddress() + } + if !filepath.IsAbs(value) { + return serverAddress{}, fmt.Errorf("Speech Dispatcher Unix socket must be an absolute path: %q", value) + } + return serverAddress{network: "unix", address: value}, nil + case "inet_socket": + host, port := "127.0.0.1", 6560 + if value != "" { + parts := strings.Split(value, ":") + if len(parts) > 2 { + return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher address %q", address) + } + if parts[0] != "" { + host = parts[0] + } + if len(parts) == 2 { + parsed, err := strconv.Atoi(parts[1]) + if err != nil || parsed < 1 || parsed > 65535 { + return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher port in %q", address) + } + port = parsed + } + } + return serverAddress{network: "tcp4", address: net.JoinHostPort(host, strconv.Itoa(port))}, nil + default: + return serverAddress{}, fmt.Errorf("unsupported Speech Dispatcher address %q", address) + } +} diff --git a/internal/speech/address_test.go b/internal/speech/address_test.go new file mode 100644 index 0000000..56b91c1 --- /dev/null +++ b/internal/speech/address_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "context" + "net" + "path/filepath" + "strconv" + "testing" +) + +func TestResolveServerAddressPrecedence(t *testing.T) { + runtimeDirectory := t.TempDir() + cacheDirectory := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtimeDirectory) + t.Setenv("XDG_CACHE_HOME", cacheDirectory) + t.Setenv("SPEECHD_SOCKET", "/tmp/legacy-speech.sock") + t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/address-speech.sock") + + for _, test := range []struct { + name, override, speechAddress, legacySocket, runtimeDir, want string + }{ + {"explicit", "/tmp/explicit-speech.sock", "unix_socket:/tmp/address-speech.sock", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/explicit-speech.sock"}, + {"address", "", "unix_socket:/tmp/address-speech.sock", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/address-speech.sock"}, + {"legacy", "", "", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/legacy-speech.sock"}, + {"runtime", "", "", "", runtimeDirectory, filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock")}, + {"cache", "", "", "", "", filepath.Join(cacheDirectory, "speech-dispatcher", "speechd.sock")}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("SPEECHD_ADDRESS", test.speechAddress) + t.Setenv("SPEECHD_SOCKET", test.legacySocket) + t.Setenv("XDG_RUNTIME_DIR", test.runtimeDir) + got, err := resolveServerAddress(test.override) + if err != nil || got.network != "unix" || got.address != test.want { + t.Fatalf("address = %+v, error = %v; want %q", got, err, test.want) + } + }) + } +} + +func TestParseServerAddress(t *testing.T) { + for _, test := range []struct { + input, network, address string + }{ + {"unix_socket:/tmp/speech.sock", "unix", "/tmp/speech.sock"}, + {"inet_socket", "tcp4", "127.0.0.1:6560"}, + {"inet_socket:localhost:6561", "tcp4", "localhost:6561"}, + } { + got, err := parseServerAddress(test.input) + if err != nil || got.network != test.network || got.address != test.address { + t.Fatalf("parse %q = %+v, %v", test.input, got, err) + } + } + for _, address := range []string{"invalid:/tmp/speech.sock", "unix_socket:relative.sock", "inet_socket:localhost:0", "inet_socket:localhost:bad"} { + if _, err := parseServerAddress(address); err == nil { + t.Fatalf("accepted invalid address %q", address) + } + } +} + +func TestClientAndDiscoveryHonorSpeechdAddress(t *testing.T) { + path, _ := newSettingsServer(t, func(command string) string { + if command == "LIST OUTPUT_MODULES" { + return "200-test\r\n200 OK\r\n" + } + return "" + }) + t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+path) + t.Setenv("SPEECHD_SOCKET", "/tmp/absent-speech.sock") + client := New("") + defer client.Close(context.Background()) + if err := client.Connect(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := Discover(context.Background(), ""); err != nil { + t.Fatal(err) + } +} + +func TestTCPAddressConnects(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + server := newSettingsServerListener(t, listener, nil) + port := listener.Addr().(*net.TCPAddr).Port + t.Setenv("SPEECHD_ADDRESS", "inet_socket:127.0.0.1:"+strconv.Itoa(port)) + client := New("") + defer client.Close(context.Background()) + if err := client.Connect(context.Background()); err != nil { + t.Fatal(err) + } + if !server.saw(1, "HISTORY GET CLIENT_ID") { + t.Fatal("speech client did not use the TCP server") + } +} diff --git a/internal/speech/autospawn.go b/internal/speech/autospawn.go new file mode 100644 index 0000000..c09341f --- /dev/null +++ b/internal/speech/autospawn.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "strings" + "time" +) + +type serverDialer func(context.Context, string, string) (net.Conn, error) + +// Like Python speechd, try the existing server first, then ask the daemon to +// spawn according to its own configuration. Never start a local daemon for a +// remote TCP endpoint, and never stop a daemon owned by the session. +func connectWithAutospawn(ctx context.Context, endpoint serverAddress, dial serverDialer, spawn func(context.Context, serverAddress) error) (net.Conn, error) { + conn, err := dial(ctx, endpoint.network, endpoint.address) + if ctx.Err() != nil { + if conn != nil { + _ = conn.Close() + } + return nil, ctx.Err() + } + if err == nil { + return conn, err + } + if spawnErr := spawn(ctx, endpoint); spawnErr != nil { + return nil, fmt.Errorf("%w; autostart Speech Dispatcher: %v", err, spawnErr) + } + retryContext, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + for { + conn, err = dial(retryContext, endpoint.network, endpoint.address) + if err == nil { + return conn, nil + } + timer := time.NewTimer(50 * time.Millisecond) + select { + case <-retryContext.Done(): + timer.Stop() + return nil, fmt.Errorf("after Speech Dispatcher autostart: %w", err) + case <-timer.C: + } + } +} + +func spawnArguments(ctx context.Context, endpoint serverAddress) ([]string, error) { + args := []string{"--spawn"} + if endpoint.network == "unix" { + return append(args, "--communication-method", "unix_socket", "--socket-path", endpoint.address), nil + } + host, port, err := net.SplitHostPort(endpoint.address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(addresses) == 0 { + return nil, fmt.Errorf("no addresses for %s", host) + } + for _, address := range addresses { + if !address.IP.IsLoopback() { + return nil, fmt.Errorf("cannot autostart a remote Speech Dispatcher at %s", host) + } + } + return append(args, "--communication-method", "inet_socket", "--port", port), nil +} + +func spawnServer(ctx context.Context, endpoint serverAddress) error { + spawnContext, cancel := context.WithTimeout(ctx, ioTimeout) + defer cancel() + args, err := spawnArguments(spawnContext, endpoint) + if err != nil { + return err + } + command := os.Getenv("SPEECHD_CMD") + if command == "" { + command = "speech-dispatcher" + } + process := exec.CommandContext(spawnContext, command, args...) + process.WaitDelay = 100 * time.Millisecond + output, err := process.CombinedOutput() + if err != nil { + return fmt.Errorf("%s --spawn: %w: %s", command, err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/internal/speech/autospawn_test.go b/internal/speech/autospawn_test.go new file mode 100644 index 0000000..ba192a4 --- /dev/null +++ b/internal/speech/autospawn_test.go @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T) { + for _, connectError := range []error{syscall.ENOENT, syscall.ECONNREFUSED} { + t.Run(connectError.Error(), func(t *testing.T) { + endpoint := serverAddress{network: "unix", address: "/tmp/selected-speech.sock"} + client, server := net.Pipe() + defer client.Close() + defer server.Close() + attempts, starts := 0, 0 + got, err := connectWithAutospawn(context.Background(), endpoint, func(_ context.Context, network, address string) (net.Conn, error) { + if network != endpoint.network || address != endpoint.address { + t.Fatal("connection address changed during autostart") + } + attempts++ + if attempts == 1 { + return nil, connectError + } + return client, nil + }, func(_ context.Context, selected serverAddress) error { + if selected != endpoint { + t.Fatal("spawn address differs from selected server") + } + starts++ + return nil + }) + if err != nil || got != client || attempts != 2 || starts != 1 { + t.Fatalf("connection = %v, %v; attempts %d, starts %d", got, err, attempts, starts) + } + }) + } +} + +func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) { + dial := func(context.Context, string, string) (net.Conn, error) { return nil, syscall.ECONNREFUSED } + starts := 0 + spawn := func(context.Context, serverAddress) error { starts++; return errors.New("autospawn disabled") } + _, err := connectWithAutospawn(context.Background(), serverAddress{}, dial, spawn) + if !errors.Is(err, syscall.ECONNREFUSED) || !strings.Contains(err.Error(), "autospawn disabled") || starts != 1 { + t.Fatalf("error = %v, starts = %d", err, starts) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _ = connectWithAutospawn(ctx, serverAddress{}, dial, spawn) + if starts != 1 { + t.Fatal("canceled connection started a daemon") + } + ctx, cancel = context.WithCancel(context.Background()) + _, err = connectWithAutospawn(ctx, serverAddress{}, func(ctx context.Context, _, _ string) (net.Conn, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, syscall.ECONNREFUSED + }, func(context.Context, serverAddress) error { cancel(); return nil }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("retry error = %v", err) + } +} + +func TestSpeechSpawnArgumentsPreserveEndpointAndRejectRemote(t *testing.T) { + for _, test := range []struct { + endpoint serverAddress + want []string + }{ + {serverAddress{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}}, + {serverAddress{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}}, + } { + args, err := spawnArguments(context.Background(), test.endpoint) + if err != nil || !reflect.DeepEqual(args, test.want) { + t.Fatalf("arguments = %v, %v", args, err) + } + } + if _, err := spawnArguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil { + t.Fatal("remote server allowed local autospawn") + } +} + +func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) { + path := filepath.Join(t.TempDir(), "fake-speech-dispatcher") + if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'autospawn disabled by daemon' >&2\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("SPEECHD_CMD", path) + err := spawnServer(context.Background(), serverAddress{"unix", "/tmp/custom.sock"}) + if err == nil || !strings.Contains(err.Error(), "autospawn disabled by daemon") { + t.Fatalf("error = %v", err) + } +} + +func TestExistingSpeechServerDoesNotInvokeSpawn(t *testing.T) { + t.Setenv("SPEECHD_CMD", "/nonexistent/speech-dispatcher") + path, _ := newSettingsServer(t, func(string) string { return "" }) + client := New(path) + if err := client.Connect(context.Background()); err != nil { + t.Fatal(err) + } + defer client.Close(context.Background()) +} + +// Run against a separate dummy-output daemon, never the user's speech server. +func TestNativeSpeechDispatcherColdStart(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_SPEECH_STARTUP") != "1" { + t.Skip("requires isolated Speech Dispatcher startup") + } + if _, err := exec.LookPath("speech-dispatcher"); err != nil { + t.Fatal(err) + } + for _, staleSocket := range []bool{false, true} { + t.Run(fmt.Sprintf("stale-socket-%v", staleSocket), func(t *testing.T) { + directory, err := os.MkdirTemp("", "speech-start-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(directory) + configPath := filepath.Join(directory, "speechd.conf") + config := "LogLevel 0\nLogDir \"" + directory + "\"\nAddModule \"dummy\" \"sd_dummy\" \"\"\nDefaultModule dummy\nTimeout 1\n" + if err := os.WriteFile(configPath, []byte(config), 0600); err != nil { + t.Fatal(err) + } + pidPath := filepath.Join(directory, "speechd.pid") + wrapper := filepath.Join(directory, "spawn") + script := "#!/bin/sh\nexec speech-dispatcher --config-dir \"$CTHULHU_TEST_SPEECH_CONFIG\" --pid-file \"$CTHULHU_TEST_SPEECH_PID\" \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("CTHULHU_TEST_SPEECH_CONFIG", directory) + t.Setenv("CTHULHU_TEST_SPEECH_PID", pidPath) + t.Setenv("SPEECHD_CMD", wrapper) + t.Setenv("XDG_RUNTIME_DIR", directory) + t.Setenv("SPEECHD_ADDRESS", "") + t.Setenv("SPEECHD_SOCKET", "") + defer func() { + if data, err := os.ReadFile(pidPath); err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + } + }() + socket := DefaultSocketAddress() + if staleSocket { + if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil { + t.Fatal(err) + } + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + listener.SetUnlinkOnClose(false) + listener.Close() + } + client := New("") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + defer client.Close(context.Background()) + if err := client.Connect(ctx); err != nil { + t.Fatal(err) + } + if client.clientID <= 0 || client.cancelConn == nil { + t.Fatal("cold-start client did not initialize both channels") + } + }) + } +} diff --git a/internal/speech/catalog.go b/internal/speech/catalog.go new file mode 100644 index 0000000..8fa338c --- /dev/null +++ b/internal/speech/catalog.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "bufio" + "context" + "fmt" + "net" + "strings" +) + +type Voice struct{ Name, Language, Variant string } +type Catalog map[string][]Voice + +// Discover uses its own connection, so slow modules never hold up speech. +// The empty module selects the server default. +func Discover(ctx context.Context, socket string) (Catalog, error) { + endpoint, err := resolveServerAddress(socket) + if err != nil { + return nil, err + } + conn, err := (&net.Dialer{Timeout: connectTimeout}).DialContext(ctx, endpoint.network, endpoint.address) + if err != nil { + return nil, err + } + defer conn.Close() + client := &Client{conn: conn, reader: bufio.NewReader(conn), ioTimeout: ioTimeout} + query := func(command string) ([]string, error) { + finish, err := prepareIO(ctx, conn, ioTimeout) + if err != nil { + return nil, err + } + defer finish() + if _, err = conn.Write([]byte(command + "\r\n")); err != nil { + return nil, err + } + reply, err := client.readReply() + if err != nil { + return nil, err + } + if reply.code < 200 || reply.code >= 300 { + return nil, fmt.Errorf("voice discovery: %s", reply.text) + } + return reply.data, nil + } + modules, err := query("LIST OUTPUT_MODULES") + if err != nil { + return nil, err + } + catalog := Catalog{} + for _, module := range append([]string{""}, modules...) { + if strings.ContainsAny(module, "\r\n\x00") { + continue + } + if module != "" { + if err := client.sendCommand(ctx, []string{"SET", "SELF", "OUTPUT_MODULE", module}); err != nil { + continue + } + } + rows, err := query("LIST SYNTHESIS_VOICES") + if err != nil { + if ctx.Err() != nil { + return catalog, ctx.Err() + } + continue + } + catalog[module] = nil + for _, row := range rows { + fields := strings.Split(row, "\t") + if len(fields) < 2 { + continue + } + voice := Voice{Name: fields[0], Language: fields[1]} + if len(fields) > 2 { + voice.Variant = fields[2] + } + catalog[module] = append(catalog[module], voice) + } + } + return catalog, nil +} diff --git a/internal/speech/catalog_test.go b/internal/speech/catalog_test.go new file mode 100644 index 0000000..0e14f4e --- /dev/null +++ b/internal/speech/catalog_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "context" + "testing" +) + +func TestDiscoverVoicesAndOutputModule(t *testing.T) { + path, server := newSettingsServer(t, func(command string) string { + switch command { + case "LIST OUTPUT_MODULES": + return "200-test\r\n200 OK\r\n" + case "LIST SYNTHESIS_VOICES": + return "200-Test Voice\ten-US\tnone\r\n200 OK\r\n" + } + return "" + }) + catalog, err := Discover(context.Background(), path) + if err != nil { + t.Fatal(err) + } + for _, module := range []string{"", "test"} { + voices := catalog[module] + if len(voices) != 1 || voices[0].Name != "Test Voice" || voices[0].Language != "en-US" { + t.Fatalf("catalog=%+v", catalog) + } + } + if !server.saw(1, "SET SELF OUTPUT_MODULE test") { + t.Fatal("did not select module for discovery") + } + client := NewWithSettings(path, Settings{OutputModule: "test", SynthesisVoice: "Test Voice"}) + defer client.Close(context.Background()) + if err := client.Connect(context.Background()); err != nil { + t.Fatal(err) + } + if !server.saw(2, "SET SELF OUTPUT_MODULE test") || !server.saw(2, "SET SELF SYNTHESIS_VOICE Test Voice") { + t.Fatal("echo voice/module not configured") + } +} diff --git a/internal/speech/hardware.go b/internal/speech/hardware.go new file mode 100644 index 0000000..f68f390 --- /dev/null +++ b/internal/speech/hardware.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "context" + "errors" + "fmt" + "math" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// Hardware ports Python Cthulhu's serial protocols. One owner serializes text +// and settings; cancellation retires a blocked write before sending Ctrl+X. +type Hardware struct { + mu sync.Mutex + ioMu sync.Mutex + fd int + synth string + generation uint64 +} + +type HardwareVoice struct{ Rate, Pitch, Volume *float64 } + +func NewHardware(device, synth string, baud int) (*Hardware, error) { + if !filepath.IsAbs(device) || strings.ContainsAny(device, "\x00\r\n") { + return nil, errors.New("hardware speech requires an explicit serial device path") + } + switch synth { + case "litetalk", "doubletalk", "tripletalk", "dectalk": + default: + return nil, errors.New("unsupported hardware speech synthesizer") + } + fd, err := open_serial(device, baud) + if err != nil { + return nil, err + } + return &Hardware{fd: fd, synth: synth}, nil +} + +func hardware_bytes(synth, text string, voice HardwareVoice) []byte { + data := []byte{} + for index, value := range []*float64{voice.Rate, voice.Pitch, voice.Volume} { + if value == nil { + continue + } + limit := 9.0 + if index == 0 { + limit = 99 + } + normalized := math.Max(0, math.Min(limit, *value)) / limit + if synth == "dectalk" { + minimum := []int{75, 50, 0}[index] + maximum := []int{650, 180, 100}[index] + command := []string{"ra", "dv ap", "vo"}[index] + data = fmt.Appendf(data, "[:%s %d]", command, int(math.RoundToEven(float64(minimum)+normalized*float64(maximum-minimum)))) + } else { + maximum := []int{9, 99, 9}[index] + command := []byte{'S', 'P', 'V'}[index] + data = fmt.Appendf(data, "\x01%d%c", int(math.RoundToEven(normalized*float64(maximum))), command) + } + } + // Devices accept ASCII. Strip control bytes and retain spacing just as + // Python's driver does. + for _, value := range text { + if value < 0x20 || value > 0x7e { + value = ' ' + } + data = append(data, byte(value)) + } + if synth == "dectalk" { + return append(data, 1) + } + return append(data, '\r') +} + +func (client *Hardware) Speak(ctx context.Context, text string, voice HardwareVoice) error { + if text == "" { + return errors.New("speech text is empty") + } + client.mu.Lock() + generation := client.generation + client.mu.Unlock() + client.ioMu.Lock() + defer client.ioMu.Unlock() + return client.write_serial(ctx, hardware_bytes(client.synth, text, voice), generation) +} + +func (client *Hardware) write_serial(ctx context.Context, data []byte, generation uint64) error { + ctx, cancel := context.WithTimeout(ctx, ioTimeout) + defer cancel() + for len(data) > 0 { + if err := ctx.Err(); err != nil { + return err + } + client.mu.Lock() + current := client.generation + client.mu.Unlock() + if generation != current { + return context.Canceled + } + if client.fd < 0 { + return errors.New("hardware speech is closed") + } + written, err := syscall.Write(client.fd, data[:min(256, len(data))]) + if written > 0 { + data = data[written:] + } + if err == syscall.EINTR { + continue + } + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK { + timer := time.NewTimer(5 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + continue + } + if err != nil { + return fmt.Errorf("write hardware speech: %w", err) + } + if written == 0 { + return errors.New("serial write returned zero bytes") + } + } + return nil +} + +func (client *Hardware) Cancel(ctx context.Context) error { + client.mu.Lock() + client.generation++ + generation := client.generation + client.mu.Unlock() + client.ioMu.Lock() + defer client.ioMu.Unlock() + if client.fd < 0 { + return nil + } + if err := flush_serial(client.fd); err != nil { + return err + } + return client.write_serial(ctx, []byte{0x18}, generation) +} + +func (client *Hardware) Close(ctx context.Context) error { + result := client.Cancel(ctx) + client.ioMu.Lock() + defer client.ioMu.Unlock() + if client.fd >= 0 { + result = errors.Join(result, syscall.Close(client.fd)) + client.fd = -1 + } + return result +} diff --git a/internal/speech/hardware_test.go b/internal/speech/hardware_test.go new file mode 100644 index 0000000..f655a01 --- /dev/null +++ b/internal/speech/hardware_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import "testing" + +func TestHardwareProtocolsMatchPython(t *testing.T) { + rate, pitch, volume := 99.0, 9.0, 9.0 + voice := HardwareVoice{Rate: &rate, Pitch: &pitch, Volume: &volume} + for _, synth := range []string{"litetalk", "doubletalk", "tripletalk", "dectalk"} { + want := "\x019S\x0199P\x019VHello !\r" + if synth == "dectalk" { + want = "[:ra 650][:dv ap 180][:vo 100]Hello !\x01" + } + if got := string(hardware_bytes(synth, "Hello\n\x01é猫!", voice)); got != want { + t.Fatalf("%s: got %q, want %q", synth, got, want) + } + } + rate, pitch, volume = 0, 0, 0 + if got := string(hardware_bytes("dectalk", "Low", voice)); got != "[:ra 75][:dv ap 50][:vo 0]Low\x01" { + t.Fatal(got) + } + rate, pitch, volume = 49.5, 4.5, 4.5 + if got := string(hardware_bytes("litetalk", "Middle", voice)); got != "\x014S\x0150P\x014VMiddle\r" { + t.Fatal(got) + } +} diff --git a/internal/speech/locale_voices.go b/internal/speech/locale_voices.go new file mode 100644 index 0000000..e3a6596 --- /dev/null +++ b/internal/speech/locale_voices.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "os" + "sort" + "strings" +) + +// LocaleVoices prefers the current locale's exact dialect, then its base +// language. An unknown locale retains the catalog, matching quick settings. +func LocaleVoices(voices []Voice) []Voice { + locale := "" + for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { + if value := os.Getenv(key); value != "" { + locale = value + break + } + } + normalize := func(value string) string { + value = strings.Split(strings.Split(value, ".")[0], "@")[0] + return strings.ToLower(strings.ReplaceAll(value, "_", "-")) + } + locale = normalize(locale) + if locale == "" || locale == "c" || locale == "posix" { + locale = "en-us" + } + for _, language := range []string{locale, strings.SplitN(locale, "-", 2)[0]} { + var selected []Voice + for _, voice := range voices { + if normalize(voice.Language) == language { + selected = append(selected, voice) + } + } + if len(selected) == 0 { + continue + } + // A '+' in the synthesis name denotes an eSpeak variant; Variant + // is gender metadata and cannot identify the unmodified voice. + sort.SliceStable(selected, func(i, j int) bool { + return !strings.Contains(selected[i].Name, "+") && strings.Contains(selected[j].Name, "+") + }) + return selected + } + return voices +} diff --git a/internal/speech/read_all.go b/internal/speech/read_all.go new file mode 100644 index 0000000..66e4b83 --- /dev/null +++ b/internal/speech/read_all.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "context" + "errors" + "time" +) + +// SpeakAndWait uses SSIP BEGIN/END/CANCEL events, not the SPEAK queue +// acknowledgement, to pace continuous reading and report its current position. +// Protocol: https://github.com/brailcom/speechd/blob/master/doc/ssip.texi +func (client *Client) SpeakAndWait(ctx context.Context, text string, began func()) (result error) { + client.mutex.Lock() + defer client.mutex.Unlock() + if client.conn == nil { + return errors.New("speech client is not connected") + } + if err := client.sendCommand(ctx, []string{"SET", "SELF", "NOTIFICATION", "ALL", "on"}); err != nil { + return err + } + defer func() { + if client.conn != nil { + cleanup, cancel := context.WithTimeout(context.Background(), client.ioTimeout) + defer cancel() + if err := client.sendCommand(cleanup, []string{"SET", "SELF", "NOTIFICATION", "ALL", "off"}); err != nil { + result = errors.Join(result, err) + _ = client.conn.Close() + client.conn, client.reader = nil, nil + } + } + }() + if err := client.speak(ctx, text); err != nil { + return err + } + conn := client.conn + // A spoken line may take much longer than the command I/O timeout. + if err := conn.SetDeadline(time.Time{}); err != nil { + return err + } + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + defer close(done) + cleanup, cancel := context.WithTimeout(context.Background(), client.ioTimeout) + defer cancel() + _ = client.Cancel(cleanup) + // Allow the final cancellation notification to be drained, preserving + // the speaking connection for subsequent focus announcements. + _ = conn.SetReadDeadline(time.Now().Add(client.ioTimeout)) + }) + defer func() { + if !stop() { + <-done + } + }() + started := false + for { + event, err := readReply(client.reader) + if err != nil { + _ = conn.Close() + client.conn, client.reader = nil, nil + return errors.Join(ctx.Err(), err) + } + switch event.code { + case 701: + if !started && ctx.Err() == nil && began != nil { + began() + } + started = true + case 702: + return ctx.Err() + case 703: + return context.Canceled + } + } +} diff --git a/internal/speech/read_all_test.go b/internal/speech/read_all_test.go new file mode 100644 index 0000000..686745c --- /dev/null +++ b/internal/speech/read_all_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "testing" + "time" +) + +func TestContinuousSpeechWaitsForEndAndSurvivesCancellation(t *testing.T) { + for _, interrupted := range []bool{false, true} { + t.Run(fmt.Sprint(interrupted), func(t *testing.T) { + conn, server := net.Pipe() + control, controlServer := net.Pipe() + client := connectedClient(conn) + client.cancelConn, client.cancelReader, client.clientID = control, bufio.NewReader(control), 42 + t.Cleanup(func() { server.Close(); controlServer.Close(); client.Close(context.Background()) }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + began := make(chan struct{}, 1) + finished := make(chan error, 1) + go func() { finished <- client.SpeakAndWait(ctx, "First line", func() { began <- struct{}{} }) }() + server.SetDeadline(time.Now().Add(2 * time.Second)) + reader := bufio.NewReader(server) + if err := exchangeSSIP(reader, server, "SET SELF NOTIFICATION ALL on\r\n", "200 OK\r\n"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"SPEAK\r\n", "First line\r\n", ".\r\n"} { + got, err := reader.ReadString('\n') + if err != nil || got != want { + t.Fatalf("speech block %q, %v; want %q", got, err, want) + } + } + fmt.Fprint(server, "230 OK\r\n225-1\r\n225 QUEUED\r\n") + select { + case <-began: + t.Fatal("queue acknowledgement advanced reading position") + case <-finished: + t.Fatal("queue acknowledgement completed speech") + default: + } + fmt.Fprint(server, "701-1\r\n701-42\r\n701 BEGIN\r\n") + select { + case <-began: + case <-time.After(time.Second): + t.Fatal("BEGIN did not update reading position") + } + if interrupted { + cancel() + controlServer.SetDeadline(time.Now().Add(time.Second)) + if err := exchangeSSIP(bufio.NewReader(controlServer), controlServer, "CANCEL 42\r\n", "200 OK\r\n"); err != nil { + t.Fatal(err) + } + fmt.Fprint(server, "703-1\r\n703-42\r\n703 CANCELED\r\n") + } else { + fmt.Fprint(server, "702-1\r\n702-42\r\n702 END\r\n") + } + if err := exchangeSSIP(reader, server, "SET SELF NOTIFICATION ALL off\r\n", "200 OK\r\n"); err != nil { + t.Fatal(err) + } + if err := <-finished; interrupted && !errors.Is(err, context.Canceled) || !interrupted && err != nil { + t.Fatalf("completion = %v", err) + } + // The cancellation notification must not become the next SPEAK reply. + go func() { finished <- client.Speak(context.Background(), "After") }() + for range 3 { + if _, err := reader.ReadString('\n'); err != nil { + t.Fatal(err) + } + } + fmt.Fprint(server, "230 OK\r\n225-2\r\n225 QUEUED\r\n") + if err := <-finished; err != nil { + t.Fatalf("speech after read all: %v", err) + } + }) + } +} diff --git a/internal/speech/serial_native.go b/internal/speech/serial_native.go new file mode 100644 index 0000000..8c24a4e --- /dev/null +++ b/internal/speech/serial_native.go @@ -0,0 +1,65 @@ +//go:build linux && cgo + +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +/* +#include +#include +#include +#include +#include + +static int cth_serial_open(const char *device, int baud) { + speed_t speed; + switch (baud) { + case 1200: speed = B1200; break; + case 2400: speed = B2400; break; + case 4800: speed = B4800; break; + case 9600: speed = B9600; break; + case 19200: speed = B19200; break; + case 38400: speed = B38400; break; + case 57600: speed = B57600; break; + case 115200: speed = B115200; break; + default: return -EINVAL; + } + int fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) return -errno; + struct termios attrs; + if (tcgetattr(fd, &attrs) < 0) { int error = errno; close(fd); return -error; } + cfmakeraw(&attrs); + attrs.c_cflag |= CLOCAL | CREAD; + attrs.c_cc[VMIN] = attrs.c_cc[VTIME] = 0; + if (cfsetispeed(&attrs, speed) < 0 || cfsetospeed(&attrs, speed) < 0 || + tcsetattr(fd, TCSANOW, &attrs) < 0) { int error = errno; close(fd); return -error; } + return fd; +} +static int cth_serial_flush(int fd) { return tcflush(fd, TCOFLUSH) < 0 ? errno : 0; } +*/ +import "C" + +import ( + "fmt" + "syscall" + "unsafe" +) + +func open_serial(device string, baud int) (int, error) { + value := C.CString(device) + defer C.free(unsafe.Pointer(value)) + fd := int(C.cth_serial_open(value, C.int(baud))) + if fd < 0 { + return -1, fmt.Errorf("open hardware speech device: %w", syscall.Errno(-fd)) + } + return fd, nil +} + +func flush_serial(fd int) error { + if code := C.cth_serial_flush(C.int(fd)); code != 0 { + return fmt.Errorf("flush hardware speech: %w", syscall.Errno(code)) + } + return nil +} diff --git a/internal/speech/serial_native_test.go b/internal/speech/serial_native_test.go new file mode 100644 index 0000000..c655993 --- /dev/null +++ b/internal/speech/serial_native_test.go @@ -0,0 +1,120 @@ +//go:build linux && cgo + +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "syscall" + "testing" + "time" + "unsafe" +) + +func serial_fixture(t *testing.T) (*os.File, string) { + t.Helper() + fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK, 0600) + if err != nil { + t.Fatal(err) + } + master := os.NewFile(uintptr(fd), "serial-test") + t.Cleanup(func() { master.Close() }) + var unlocked int32 + var number uint32 + if _, _, code := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlocked))); code != 0 { + t.Fatal(code) + } + if _, _, code := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); code != 0 { + t.Fatal(code) + } + return master, fmt.Sprintf("/dev/pts/%d", number) +} + +func TestHardwareSerialWritesCancelsAndCloses(t *testing.T) { + for _, synth := range []string{"litetalk", "doubletalk", "tripletalk", "dectalk"} { + t.Run(synth, func(t *testing.T) { + master, device := serial_fixture(t) + client, err := NewHardware(device, synth, 9600) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { client.Close(context.Background()) }) + if err := client.Speak(context.Background(), "Serial test", HardwareVoice{}); err != nil { + t.Fatal(err) + } + want := hardware_bytes(synth, "Serial test", HardwareVoice{}) + master.SetReadDeadline(time.Now().Add(time.Second)) + buffer := make([]byte, 256) + n, err := master.Read(buffer) + if err != nil || !bytes.Equal(buffer[:n], want) { + t.Fatalf("bytes=%q error=%v", buffer[:n], err) + } + if err := client.Cancel(context.Background()); err != nil { + t.Fatal(err) + } + n, err = master.Read(buffer) + if err != nil || !bytes.Equal(buffer[:n], []byte{0x18}) { + t.Fatalf("cancel=%q error=%v", buffer[:n], err) + } + if err := client.Close(context.Background()); err != nil { + t.Fatal(err) + } + if err := client.Speak(context.Background(), "Closed", HardwareVoice{}); err == nil { + t.Fatal("closed serial owner accepted speech") + } + }) + } + if client, err := NewHardware("/dev/null", "dectalk", 9600); err == nil { + client.Close(context.Background()) + t.Fatal("non-terminal device accepted") + } +} + +func TestHardwareCancellationRetiresBlockedSerialWrite(t *testing.T) { + _, device := serial_fixture(t) + client, err := NewHardware(device, "litetalk", 9600) + if err != nil { + t.Fatal(err) + } + defer client.Close(context.Background()) + done := make(chan error, 1) + go func() { done <- client.Speak(context.Background(), strings.Repeat("x", 1024*1024), HardwareVoice{}) }() + // Wait until the PTY output queue fills, then use the same cancellation + // path used by keyboard input. No reader drains that queue. + deadline := time.Now().Add(time.Second) + for { + client.mu.Lock() + locked := !client.ioMu.TryLock() + if !locked { + client.ioMu.Unlock() + } + client.mu.Unlock() + if locked { + break + } + if time.Now().After(deadline) { + t.Fatal("serial write did not start") + } + time.Sleep(time.Millisecond) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := client.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != context.Canceled { + t.Fatalf("blocked write: %v", err) + } + case <-ctx.Done(): + t.Fatal("cancellation did not release serial writer") + } +} diff --git a/internal/speech/serial_unavailable.go b/internal/speech/serial_unavailable.go new file mode 100644 index 0000000..f52e167 --- /dev/null +++ b/internal/speech/serial_unavailable.go @@ -0,0 +1,14 @@ +//go:build !linux || !cgo + +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import "errors" + +func open_serial(string, int) (int, error) { + return -1, errors.New("hardware speech requires Linux and cgo") +} +func flush_serial(int) error { return errors.New("hardware speech requires Linux and cgo") } diff --git a/internal/speech/settings_test.go b/internal/speech/settings_test.go new file mode 100644 index 0000000..580a547 --- /dev/null +++ b/internal/speech/settings_test.go @@ -0,0 +1,258 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speech + +import ( + "bufio" + "context" + "fmt" + "net" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// Each connection gets its own client ID and command history, allowing tests +// to detect settings sent to the old connection or cancellation of the wrong ID. +type settingsServer struct { + mu sync.Mutex + commands map[int][]string + connections []net.Conn + hook func(string) string +} + +func newSettingsServer(t *testing.T, hook func(string) string) (string, *settingsServer) { + t.Helper() + path := filepath.Join(t.TempDir(), "speech.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + return path, newSettingsServerListener(t, listener, hook) +} + +func newSettingsServerListener(t *testing.T, listener net.Listener, hook func(string) string) *settingsServer { + t.Helper() + server := &settingsServer{commands: make(map[int][]string), hook: hook} + var handlers sync.WaitGroup + accepted := make(chan struct{}) + go func() { + defer close(accepted) + for id := 1; ; id++ { + conn, err := listener.Accept() + if err != nil { + return + } + server.mu.Lock() + server.connections = append(server.connections, conn) + server.mu.Unlock() + handlers.Add(1) + go func(id int) { + defer handlers.Done() + defer conn.Close() + reader := bufio.NewReader(conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + command := strings.TrimSpace(line) + server.mu.Lock() + server.commands[id] = append(server.commands[id], command) + server.mu.Unlock() + response := "200 OK\r\n" + if command == "HISTORY GET CLIENT_ID" { + response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id) + } + if command == "SPEAK" { + for { + text, err := reader.ReadString('\n') + if err != nil { + return + } + if text == ".\r\n" { + break + } + } + response = "230 OK\r\n200 QUEUED\r\n" + } + if server.hook != nil { + if override := server.hook(command); override != "" { + response = override + } + } + if _, err := conn.Write([]byte(response)); err != nil { + return + } + } + }(id) + } + }() + t.Cleanup(func() { + listener.Close() + <-accepted + server.mu.Lock() + for _, conn := range server.connections { + conn.Close() + } + server.mu.Unlock() + handlers.Wait() + }) + return server +} + +func (server *settingsServer) saw(id int, command string) bool { + server.mu.Lock() + defer server.mu.Unlock() + for _, got := range server.commands[id] { + if got == command { + return true + } + } + return false +} + +func TestUpdateSettingsSwitchesSpeechAndCancellation(t *testing.T) { + path, server := newSettingsServer(t, nil) + rate := 20 + client := NewWithSettings(path, Settings{Rate: &rate}) + rate = 80 // The constructor must own its settings. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + defer client.Close(ctx) + if err := client.Connect(ctx); err != nil { + t.Fatal(err) + } + if !server.saw(1, "SET SELF RATE 20") { + t.Fatal("constructor retained caller's rate pointer") + } + if err := client.UpdateSettings(ctx, Settings{Punctuation: "all"}); err != nil { + t.Fatal(err) + } + if err := client.Speak(ctx, "new settings"); err != nil { + t.Fatal(err) + } + if err := client.Cancel(ctx); err != nil { + t.Fatal(err) + } + if !server.saw(3, "SET SELF PUNCTUATION all") || !server.saw(3, "SPEAK") { + t.Fatal("new settings and speech did not use replacement connection") + } + if server.saw(3, "SET SELF RATE 20") || client.settings.Rate != nil { + t.Fatal("omitted rate did not return to fresh-connection defaults") + } + if !server.saw(2, "CANCEL 1") || !server.saw(4, "CANCEL 3") { + t.Fatal("cancellation did not follow the speaking connection") + } +} + +func TestUpdateSettingsRejectionPreservesActiveClient(t *testing.T) { + path, server := newSettingsServer(t, func(command string) string { + if command == "SET SELF PITCH 40" { + return "500 REJECTED\r\n" + } + return "" + }) + client := New(path) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + defer client.Close(ctx) + if err := client.Connect(ctx); err != nil { + t.Fatal(err) + } + rate, pitch := 25, 40 + if err := client.UpdateSettings(ctx, Settings{Rate: &rate, Pitch: &pitch}); err == nil { + t.Fatal("rejected update succeeded") + } + if err := client.Speak(ctx, "still speaking"); err != nil { + t.Fatal(err) + } + if server.saw(2, "CANCEL 1") || server.saw(1, "SET SELF RATE 25") || !server.saw(1, "SPEAK") { + t.Fatal("rejected candidate affected active speech") + } + if client.settings.Rate != nil { + t.Fatal("rejected settings were committed") + } + if err := client.Cancel(ctx); err != nil { + t.Fatal(err) + } +} + +func TestUpdateSettingsPreparationDoesNotBlockSpeechOrReviveClosedClient(t *testing.T) { + preparing, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + path, _ := newSettingsServer(t, func(command string) string { + if command == "SET SELF RATE 30" { + close(preparing) + <-release + } + return "" + }) + defer unblock() + client := New(path) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + defer client.Close(ctx) + if err := client.Connect(ctx); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { rate := 30; done <- client.UpdateSettings(ctx, Settings{Rate: &rate}) }() + select { + case <-preparing: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if err := client.UpdateSettings(ctx, Settings{}); err == nil || !strings.Contains(err.Error(), "already in progress") { + t.Fatalf("overlapping update = %v, want busy error", err) + } + if err := client.Speak(ctx, "while preparing"); err != nil { + t.Fatal(err) + } + if err := client.Cancel(ctx); err != nil { + t.Fatal(err) + } + if err := client.Close(ctx); err != nil { + t.Fatal(err) + } + unblock() + select { + case err := <-done: + if err == nil { + t.Fatal("update revived closed client") + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if client.conn != nil || client.cancelConn != nil { + t.Fatal("closed client regained connections") + } +} + +func TestUpdateSettingsCanceledContext(t *testing.T) { + path, server := newSettingsServer(t, nil) + client := New(path) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + defer client.Close(ctx) + if err := client.Connect(ctx); err != nil { + t.Fatal(err) + } + canceled, stop := context.WithCancel(ctx) + stop() + if err := client.UpdateSettings(canceled, Settings{}); err == nil { + t.Fatal("canceled update succeeded") + } + if err := client.Speak(ctx, "unchanged"); err != nil { + t.Fatal(err) + } + if server.saw(2, "CANCEL 1") { + t.Fatal("canceled update interrupted speech") + } +} diff --git a/internal/speech/speech.go b/internal/speech/speech.go new file mode 100644 index 0000000..ef7c693 --- /dev/null +++ b/internal/speech/speech.go @@ -0,0 +1,471 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package speech + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" +) + +const ( + connectTimeout = 2 * time.Second + ioTimeout = 5 * time.Second +) + +type Client struct { + socketAddress string + settings Settings + conn net.Conn + reader *bufio.Reader + cancelConn net.Conn + cancelReader *bufio.Reader + clientID int + ioTimeout time.Duration + mutex sync.Mutex + cancelMutex sync.Mutex + settingsMutex sync.Mutex +} + +type Settings struct { + OutputModule string + Rate *int + Pitch *int + Volume *int + Language string + SynthesisVoice string + Punctuation string + Capitalization string +} + +type reply struct { + code int + text string + data []string +} + +func New(socketAddress string) *Client { + return NewWithSettings(socketAddress, Settings{}) +} + +func NewWithSettings(socketAddress string, settings Settings) *Client { + return &Client{socketAddress: socketAddress, settings: cloneSettings(settings), ioTimeout: ioTimeout} +} + +func cloneSettings(settings Settings) Settings { + for _, value := range []**int{&settings.Rate, &settings.Pitch, &settings.Volume} { + if *value != nil { + copy := **value + *value = © + } + } + return settings +} + +// UpdateSettings replaces the complete speech configuration. A fresh pair of +// connections lets omitted settings regain server defaults and prevents a +// rejected SET command from leaving the active client partially configured. +// Preparation leaves Speak and Cancel available. Commit serializes with Speak, +// cancels the old client's queued speech, and switches the cancellation target. +// Only one update may prepare connections at a time; overlapping calls fail. +// Call this from a settings worker, never from the focus or input event path. +func (client *Client) UpdateSettings(ctx context.Context, settings Settings) error { + if !client.settingsMutex.TryLock() { + return errors.New("speech settings update already in progress") + } + defer client.settingsMutex.Unlock() + ctx, cancel := context.WithTimeout(ctx, ioTimeout) + defer cancel() + client.mutex.Lock() + original := client.conn + client.mutex.Unlock() + if original == nil { + return errors.New("speech client is not connected") + } + + candidate := NewWithSettings(client.socketAddress, settings) + defer candidate.Close(context.Background()) + if err := candidate.Connect(ctx); err != nil { + return fmt.Errorf("prepare speech settings: %w", err) + } + + client.mutex.Lock() + defer client.mutex.Unlock() + client.cancelMutex.Lock() + defer client.cancelMutex.Unlock() + if client.conn != original { + return errors.New("speech connection changed while preparing settings") + } + if err := ctx.Err(); err != nil { + return err + } + if err := client.sendCancelCommand(ctx, "CANCEL "+strconv.Itoa(client.clientID)); err != nil { + return fmt.Errorf("cancel speech before applying settings: %w", err) + } + + // Swap ownership so the deferred close releases only the retired pair. + client.conn, candidate.conn = candidate.conn, client.conn + client.reader, candidate.reader = candidate.reader, client.reader + client.cancelConn, candidate.cancelConn = candidate.cancelConn, client.cancelConn + client.cancelReader, candidate.cancelReader = candidate.cancelReader, client.cancelReader + client.clientID, candidate.clientID = candidate.clientID, client.clientID + client.settings = candidate.settings + return nil +} + +func (client *Client) Connect(ctx context.Context) error { + client.mutex.Lock() + defer client.mutex.Unlock() + client.cancelMutex.Lock() + defer client.cancelMutex.Unlock() + if client.conn != nil || client.cancelConn != nil { + return errors.New("speech client already connected") + } + + endpoint, err := resolveServerAddress(client.socketAddress) + if err != nil { + return err + } + dialer := net.Dialer{Timeout: connectTimeout} + conn, err := connectWithAutospawn(ctx, endpoint, dialer.DialContext, spawnServer) + if err != nil { + return fmt.Errorf("connect to Speech Dispatcher at %s: %w", endpoint.address, err) + } + cancelConn, err := dialer.DialContext(ctx, endpoint.network, endpoint.address) + if err != nil { + _ = conn.Close() + return fmt.Errorf("connect cancellation channel to Speech Dispatcher at %s: %w", endpoint.address, err) + } + client.conn = conn + client.reader = bufio.NewReader(conn) + client.cancelConn = cancelConn + client.cancelReader = bufio.NewReader(cancelConn) + if err := client.initialize(ctx); err != nil { + _ = conn.Close() + _ = cancelConn.Close() + client.conn, client.reader = nil, nil + client.cancelConn, client.cancelReader = nil, nil + client.clientID = 0 + return err + } + return nil +} + +// initialize runs with both connection locks held. Cancellation always targets +// the speaking connection's ID, never the separate control connection or ALL. +func (client *Client) initialize(ctx context.Context) error { + if err := client.applySettings(ctx); err != nil { + return err + } + finish, err := prepareIO(ctx, client.conn, client.ioTimeout) + if err != nil { + return err + } + defer finish() + if _, err := client.conn.Write([]byte("HISTORY GET CLIENT_ID\r\n")); err != nil { + return fmt.Errorf("request speech client ID: %w", err) + } + response, err := client.readReply() + if err != nil { + return err + } + if response.code != 245 || len(response.data) != 1 { + return fmt.Errorf("invalid Speech Dispatcher client ID reply: %d %s (expected code 245 and one ID)", response.code, response.text) + } + clientID, err := strconv.Atoi(response.data[0]) + if err != nil || clientID <= 0 { + return errors.New("invalid Speech Dispatcher client ID") + } + client.clientID = clientID + return client.sendCancelCommand(ctx, "SET SELF CLIENT_NAME cthulhu:cthulhu:cancel") +} + +func (client *Client) applySettings(ctx context.Context) error { + commands := [][]string{ + {"SET", "SELF", "CLIENT_NAME", "cthulhu:cthulhu:main"}, + {"SET", "SELF", "PRIORITY", "MESSAGE"}, + } + if client.settings.OutputModule != "" { + commands = append(commands, []string{"SET", "SELF", "OUTPUT_MODULE", client.settings.OutputModule}) + } + if client.settings.Punctuation != "" { + commands = append(commands, []string{"SET", "SELF", "PUNCTUATION", client.settings.Punctuation}) + } + if client.settings.Capitalization != "" { + commands = append(commands, []string{"SET", "SELF", "CAP_LET_RECOGN", client.settings.Capitalization}) + } + for _, setting := range []struct { + name string + value *int + }{ + {name: "RATE", value: client.settings.Rate}, + {name: "PITCH", value: client.settings.Pitch}, + {name: "VOLUME", value: client.settings.Volume}, + } { + if setting.value != nil { + commands = append(commands, []string{"SET", "SELF", setting.name, strconv.Itoa(*setting.value)}) + } + } + if client.settings.Language != "" { + commands = append(commands, []string{"SET", "SELF", "LANGUAGE", client.settings.Language}) + } + if client.settings.SynthesisVoice != "" { + commands = append(commands, []string{"SET", "SELF", "SYNTHESIS_VOICE", client.settings.SynthesisVoice}) + } + + for _, command := range commands { + if err := client.sendCommand(ctx, command); err != nil { + return fmt.Errorf("configure Speech Dispatcher %s: %w", command[2], err) + } + } + return nil +} + +func (client *Client) sendCommand(ctx context.Context, fields []string) error { + for _, field := range fields { + if strings.ContainsAny(field, "\r\n\x00") { + return errors.New("SSIP command contains a control character") + } + } + finish, err := prepareIO(ctx, client.conn, client.ioTimeout) + if err != nil { + return err + } + defer finish() + if _, err := client.conn.Write([]byte(strings.Join(fields, " ") + "\r\n")); err != nil { + return fmt.Errorf("send SSIP command: %w", err) + } + commandReply, err := client.readReply() + if err != nil { + return err + } + if commandReply.code < 200 || commandReply.code >= 300 { + return fmt.Errorf("SSIP command rejected with %d %s", commandReply.code, commandReply.text) + } + return nil +} + +func (client *Client) Speak(ctx context.Context, text string) (result error) { + client.mutex.Lock() + defer client.mutex.Unlock() + return client.speak(ctx, text) +} + +// SpeakCharacter gives the synthesizer a single character as a character, +// preserving repeated adjacent letters during caret and flat review. Spoken +// names such as "space" still use normal speech. +func (client *Client) SpeakCharacter(ctx context.Context, text string) error { + client.mutex.Lock() + defer client.mutex.Unlock() + if utf8.RuneCountInString(text) != 1 || strings.ContainsAny(text, "\r\n\x00") { + return client.speak(ctx, text) + } + if client.conn == nil { + return errors.New("speech client is not connected") + } + return client.sendCommand(ctx, []string{"CHAR", text}) +} + +func (client *Client) speak(ctx context.Context, text string) (result error) { + text, err := normalizeSpeakText(text) + if err != nil { + return err + } + + if client.conn == nil { + return errors.New("speech client is not connected") + } + finish, err := prepareIO(ctx, client.conn, client.ioTimeout) + if err != nil { + return err + } + defer finish() + + defer func() { + if result != nil { + // A partial exchange cannot safely be reused: a late reply could + // otherwise be mistaken for the next command's acknowledgment. + _ = client.conn.Close() + client.conn, client.reader = nil, nil + } + }() + // SSIP doubles a leading dot so it cannot terminate the speech block. + if strings.HasPrefix(text, ".") { + text = "." + text + } + request := "SPEAK\r\n" + text + "\r\n.\r\n" + if _, err := client.conn.Write([]byte(request)); err != nil { + return fmt.Errorf("send speech block: %w", err) + } + accepted, err := client.readReply() + if err != nil { + return err + } + if accepted.code != 230 { + return fmt.Errorf("speech block rejected with %d %s", accepted.code, accepted.text) + } + + queued, err := client.readReply() + if err != nil { + return err + } + if queued.code < 200 || queued.code >= 300 { + return fmt.Errorf("speech queue rejected with %d %s", queued.code, queued.text) + } + return nil +} + +func (client *Client) Cancel(ctx context.Context) error { + client.cancelMutex.Lock() + defer client.cancelMutex.Unlock() + return client.sendCancelCommand(ctx, "CANCEL "+strconv.Itoa(client.clientID)) +} + +func (client *Client) Close(context.Context) error { + client.mutex.Lock() + defer client.mutex.Unlock() + client.cancelMutex.Lock() + defer client.cancelMutex.Unlock() + conn := client.conn + client.conn = nil + client.reader = nil + cancelConn := client.cancelConn + client.cancelConn = nil + client.cancelReader = nil + client.clientID = 0 + + if conn == nil && cancelConn == nil { + return nil + } + + closeError := error(nil) + if conn != nil { + if err := conn.Close(); err != nil { + closeError = fmt.Errorf("close Speech Dispatcher connection: %w", err) + } + } + if cancelConn != nil { + if err := cancelConn.Close(); err != nil { + cancelError := fmt.Errorf("close Speech Dispatcher cancellation connection: %w", err) + closeError = errors.Join(closeError, cancelError) + } + } + return closeError +} + +// prepareIO makes cancellation interrupt blocked socket I/O. The cleanup waits +// for a started callback before the next operation can install its deadline. +func prepareIO(ctx context.Context, conn net.Conn, timeout time.Duration) (func(), error) { + if err := ctx.Err(); err != nil { + return nil, err + } + deadline := time.Now().Add(timeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if err := conn.SetDeadline(deadline); err != nil { + return nil, fmt.Errorf("set Speech Dispatcher deadline: %w", err) + } + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + _ = conn.SetDeadline(time.Now()) + close(done) + }) + return func() { + if !stop() { + <-done + } + }, nil +} + +func (client *Client) readReply() (reply, error) { + for { + response, err := readReply(client.reader) + if err != nil || response.code < 700 || response.code >= 800 { + return response, err + } + } +} + +func (client *Client) sendCancelCommand(ctx context.Context, command string) (result error) { + if client.cancelConn == nil { + return errors.New("speech cancellation channel is not connected") + } + finish, err := prepareIO(ctx, client.cancelConn, client.ioTimeout) + if err != nil { + return err + } + defer finish() + defer func() { + if result != nil { + _ = client.cancelConn.Close() + client.cancelConn, client.cancelReader = nil, nil + } + }() + if _, err := client.cancelConn.Write([]byte(command + "\r\n")); err != nil { + return fmt.Errorf("send Speech Dispatcher cancellation command: %w", err) + } + cancelReply, err := readReply(client.cancelReader) + if err != nil { + return err + } + if cancelReply.code < 200 || cancelReply.code >= 300 { + return fmt.Errorf("Speech Dispatcher cancellation rejected with %d %s", cancelReply.code, cancelReply.text) + } + return nil +} + +func readReply(reader *bufio.Reader) (reply, error) { + var final reply + for { + line, err := reader.ReadString('\n') + if err != nil { + return final, fmt.Errorf("read Speech Dispatcher reply: %w", err) + } + line = strings.TrimRight(line, "\r\n") + if len(line) < 4 || line[3] != '-' && line[3] != ' ' { + return final, fmt.Errorf("invalid Speech Dispatcher reply %q", line) + } + code, err := strconv.Atoi(line[:3]) + if err != nil { + return final, fmt.Errorf("parse Speech Dispatcher reply %q: %w", line, err) + } + final.code = code + final.text = strings.TrimSpace(line[4:]) + if line[3] == ' ' { + return final, nil + } + final.data = append(final.data, final.text) + } +} + +func normalizeSpeakText(text string) (string, error) { + text = strings.ToValidUTF8(text, "�") + text = strings.Join(strings.Fields(text), " ") + if text == "" { + return "", errors.New("speech text is empty") + } + return text, nil +} diff --git a/internal/speech/speech_test.go b/internal/speech/speech_test.go new file mode 100644 index 0000000..9ac63b8 --- /dev/null +++ b/internal/speech/speech_test.go @@ -0,0 +1,471 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package speech + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" +) + +func TestSpeakSendsSSIPAndAcceptsQueuedReply(t *testing.T) { + connection, done := startSSIPServer(t, func(reader *bufio.Reader) error { + request, err := reader.ReadString('\n') + if err != nil { + return err + } + if request != "SPEAK\r\n" { + return errors.New("missing SPEAK command") + } + text, err := reader.ReadString('\n') + if err != nil { + return err + } + if text != "Generic focus text\r\n" { + return fmt.Errorf("speech text = %q", text) + } + if terminator, err := reader.ReadString('\n'); err != nil || terminator != ".\r\n" { + if err != nil { + return err + } + return errors.New("invalid speech block terminator") + } + return nil + }, "230 OK RECEIVING DATA\r\n225-1\r\n225 OK MESSAGE QUEUED\r\n") + client := connectedClient(connection) + if err := client.Speak(context.Background(), "Generic focus text"); err != nil { + t.Fatalf("Speak: %v", err) + } + if err := client.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("SSIP server error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for fake SSIP server") + } +} + +func TestSpeakCharacterSendsEachRepeatedCharacter(t *testing.T) { + connection, server := net.Pipe() + client := connectedClient(connection) + defer client.Close(context.Background()) + defer server.Close() + done := make(chan error, 1) + characters := []string{"o", "o", "1", "1", "1", "l", "l"} + go func() { + reader := bufio.NewReader(server) + for _, character := range characters { + if err := exchangeSSIP(reader, server, "CHAR "+character+"\r\n", "225 OK MESSAGE QUEUED\r\n"); err != nil { + done <- err + return + } + } + done <- nil + }() + for _, character := range characters { + if err := client.SpeakCharacter(context.Background(), character); err != nil { + t.Fatal(err) + } + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestCancelSendsSSIPCommand(t *testing.T) { + connection, done := startSSIPServer(t, func(reader *bufio.Reader) error { + request, err := reader.ReadString('\n') + if err != nil { + return err + } + if request != "CANCEL 42\r\n" { + return errors.New("unexpected cancel command") + } + return nil + }, "200 OK CANCELLED\r\n") + client := New("") + client.cancelConn = connection + client.cancelReader = bufio.NewReader(connection) + client.clientID = 42 + if err := client.Cancel(context.Background()); err != nil { + t.Fatalf("Cancel: %v", err) + } + if err := client.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("SSIP server error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for fake SSIP server") + } +} + +func TestApplySettingsSendsConfiguredSSIPCommands(t *testing.T) { + rate, pitch, volume := 52, 0, 40 + connection, serverConnection := net.Pipe() + client := connectedClient(connection) + client.settings = Settings{ + Rate: &rate, + Pitch: &pitch, + Volume: &volume, + Language: "en-US", + SynthesisVoice: "Generic Voice", + Punctuation: "some", + Capitalization: "spell", + } + expected := []string{ + "SET SELF CLIENT_NAME cthulhu:cthulhu:main\r\n", + "SET SELF PRIORITY MESSAGE\r\n", + "SET SELF PUNCTUATION some\r\n", + "SET SELF CAP_LET_RECOGN spell\r\n", + "SET SELF RATE 52\r\n", + "SET SELF PITCH 0\r\n", + "SET SELF VOLUME 40\r\n", + "SET SELF LANGUAGE en-US\r\n", + "SET SELF SYNTHESIS_VOICE Generic Voice\r\n", + } + done := make(chan error, 1) + go func() { + reader := bufio.NewReader(serverConnection) + for _, want := range expected { + got, err := reader.ReadString('\n') + if err != nil { + done <- err + return + } + if got != want { + done <- fmt.Errorf("SSIP command = %q, want %q", got, want) + return + } + if _, err := serverConnection.Write([]byte("200 OK\r\n")); err != nil { + done <- err + return + } + } + done <- nil + }() + t.Cleanup(func() { serverConnection.Close() }) + + if err := client.applySettings(context.Background()); err != nil { + t.Fatalf("applySettings: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for configured SSIP commands") + } +} + +func TestNormalizeSpeakText(t *testing.T) { + for _, test := range []struct { + name, input, want string + }{ + {"line breaks", " Generic\r\nlabel ", "Generic label"}, + {"punctuation", ".", "."}, + {"tabs and spacing", "\tRoom 123\tA12\n", "Room 123 A12"}, + {"Unicode whitespace", "\u00a0Café\u2003猫\u2028text\u3000", "Café 猫 text"}, + {"combining characters", "\te\u0301 saveFile!\n", "e\u0301 saveFile!"}, + {"invalid UTF-8", "\x80bad\xfftext", "�bad�text"}, + {"empty", "", ""}, + {"whitespace only", " \r\n\t\u00a0\u2003", ""}, + } { + t.Run(test.name, func(t *testing.T) { + text, err := normalizeSpeakText(test.input) + if text != test.want || (err != nil) != (test.want == "") { + t.Fatalf("normalizeSpeakText(%q) = %q, %v; want %q", test.input, text, err, test.want) + } + if err != nil && err.Error() != "speech text is empty" { + t.Fatalf("empty speech error = %v", err) + } + }) + } +} + +func FuzzNormalizeSpeakTextPreservesOutput(f *testing.F) { + for _, text := range []string{"", " Generic\r\nlabel ", ".", "\tRoom 123 A12", "Café\u00a0猫\u2003text", "e\u0301 saveFile!", "\x80bad\xfftext", " \r\n\t\u00a0\u2003", "text\x00with\vcontrols"} { + f.Add(text) + } + f.Fuzz(func(t *testing.T, text string) { + // Retain the previous normalization pipeline as a behavior oracle. + expected := strings.ToValidUTF8(text, "�") + expected = strings.NewReplacer("\r", " ", "\n", " ").Replace(expected) + expected = strings.TrimSpace(strings.Join(strings.Fields(expected), " ")) + actual, err := normalizeSpeakText(text) + if actual != expected || (err != nil) != (expected == "") { + t.Fatalf("normalization changed for %q: got %q, %v; want %q", text, actual, err, expected) + } + }) +} + +func BenchmarkNormalizeSpeakText(b *testing.B) { + for _, sample := range []struct{ name, text string }{ + {"short", "Save button"}, + {"whitespace", " Example\r\nline\twith multiple spaces. "}, + } { + b.Run(sample.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := normalizeSpeakText(sample.text); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func startSSIPServer( + t *testing.T, + readRequest func(*bufio.Reader) error, + reply string, +) (net.Conn, chan error) { + t.Helper() + clientConnection, serverConnection := net.Pipe() + done := make(chan error, 1) + + go func() { + defer serverConnection.Close() + reader := bufio.NewReader(serverConnection) + if err := readRequest(reader); err != nil { + done <- err + return + } + if _, err := serverConnection.Write([]byte(reply)); err != nil { + done <- err + return + } + done <- nil + }() + t.Cleanup(func() { + clientConnection.Close() + serverConnection.Close() + }) + return clientConnection, done +} + +func connectedClient(connection net.Conn) *Client { + client := New("") + client.conn = connection + client.reader = bufio.NewReader(connection) + return client +} + +func TestInitializeTargetsSpeakingConnection(t *testing.T) { + for _, test := range []struct { + name string + reply string + valid bool + }{ + {"valid", "245-42\r\n245 OK CLIENT ID SENT\r\n", true}, + {"missing", "245 OK\r\n", false}, + {"not numeric", "245-SELF\r\n245 OK\r\n", false}, + {"zero", "245-0\r\n245 OK\r\n", false}, + {"negative", "245--1\r\n245 OK\r\n", false}, + {"wrong code", "200-42\r\n200 OK\r\n", false}, + {"rejected", "500 UNKNOWN COMMAND\r\n", false}, + {"multiple", "245-42\r\n245-43\r\n245 OK\r\n", false}, + } { + t.Run(test.name, func(t *testing.T) { + mainConn, mainServer := net.Pipe() + controlConn, controlServer := net.Pipe() + client := connectedClient(mainConn) + client.cancelConn, client.cancelReader = controlConn, bufio.NewReader(controlConn) + t.Cleanup(func() { + mainServer.Close() + controlServer.Close() + client.Close(context.Background()) + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + reader := bufio.NewReader(mainServer) + for _, exchange := range []struct{ command, reply string }{ + {"SET SELF CLIENT_NAME cthulhu:cthulhu:main\r\n", "200 OK\r\n"}, + {"SET SELF PRIORITY MESSAGE\r\n", "200 OK\r\n"}, + {"HISTORY GET CLIENT_ID\r\n", test.reply}, + } { + if err := exchangeSSIP(reader, mainServer, exchange.command, exchange.reply); err != nil { + done <- err + return + } + } + if test.valid { + reader = bufio.NewReader(controlServer) + for _, command := range []string{ + "SET SELF CLIENT_NAME cthulhu:cthulhu:cancel\r\n", + "CANCEL 42\r\n", + } { + if err := exchangeSSIP(reader, controlServer, command, "200 OK\r\n"); err != nil { + done <- err + return + } + } + } + done <- nil + }() + err := client.initialize(ctx) + if (err == nil) != test.valid { + t.Fatalf("initialize error = %v, valid = %v", err, test.valid) + } + if test.valid { + if err := client.Cancel(ctx); err != nil { + t.Fatal(err) + } + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + }) + } +} + +func exchangeSSIP(reader *bufio.Reader, conn net.Conn, command, reply string) error { + got, err := reader.ReadString('\n') + if err != nil { + return err + } + if got != command { + return fmt.Errorf("command = %q, want %q", got, command) + } + _, err = conn.Write([]byte(reply)) + return err +} + +func TestCancelWhileSpeakWaitsForReply(t *testing.T) { + mainConn, mainServer := net.Pipe() + controlConn, controlDone := startSSIPServer(t, func(reader *bufio.Reader) error { + got, err := reader.ReadString('\n') + if err == nil && got != "CANCEL 42\r\n" { + return fmt.Errorf("cancel = %q", got) + } + return err + }, "200 OK\r\n") + client := connectedClient(mainConn) + client.cancelConn, client.cancelReader, client.clientID = controlConn, bufio.NewReader(controlConn), 42 + t.Cleanup(func() { mainServer.Close(); client.Close(context.Background()) }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + speakDone := make(chan error, 1) + go func() { speakDone <- client.Speak(ctx, "Generic text") }() + reader := bufio.NewReader(mainServer) + for range 3 { + if _, err := reader.ReadString('\n'); err != nil { + t.Fatal(err) + } + } + if err := client.Cancel(ctx); err != nil { + t.Fatalf("Cancel during SPEAK: %v", err) + } + if err := <-controlDone; err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-speakDone: + if err == nil { + t.Fatal("blocked Speak succeeded") + } + case <-time.After(time.Second): + t.Fatal("Speak ignored context cancellation") + } + if err := client.Speak(context.Background(), "Next"); err == nil { + t.Fatal("reused interrupted protocol exchange") + } +} + +func TestCancelContextAndFailedChannel(t *testing.T) { + conn, server := net.Pipe() + client := New("") + client.cancelConn, client.cancelReader, client.clientID = conn, bufio.NewReader(conn), 42 + t.Cleanup(func() { server.Close(); client.Close(context.Background()) }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := client.Cancel(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Cancel = %v", err) + } + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- client.Cancel(ctx) }() + if _, err := bufio.NewReader(server).ReadString('\n'); err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("Cancel succeeded without reply") + } + case <-time.After(time.Second): + t.Fatal("Cancel ignored context cancellation") + } + if err := client.Cancel(context.Background()); err == nil { + t.Fatal("reused failed control channel") + } +} + +func TestSpeakEscapesLeadingDots(t *testing.T) { + for _, text := range []string{".", "..", ".hidden", "ordinary"} { + t.Run(text, func(t *testing.T) { + wireText := text + if text[0] == '.' { + wireText = "." + text + } + conn, done := startSSIPServer(t, func(reader *bufio.Reader) error { + for _, want := range []string{"SPEAK\r\n", wireText + "\r\n", ".\r\n"} { + got, err := reader.ReadString('\n') + if err != nil { + return err + } + if got != want { + return fmt.Errorf("wire text = %q, want %q", got, want) + } + } + return nil + }, "230 OK RECEIVING DATA\r\n225-1\r\n225 OK MESSAGE QUEUED\r\n") + client := connectedClient(conn) + if err := client.Speak(context.Background(), text); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + client.Close(context.Background()) + }) + } +} diff --git a/internal/speechhistoryui/native.c b/internal/speechhistoryui/native.c new file mode 100644 index 0000000..82f5d18 --- /dev/null +++ b/internal/speechhistoryui/native.c @@ -0,0 +1,221 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native.h" +#include +#include + +enum { NONE, COPY, REMOVE, CLOSE, CLEAR }; +static GtkWidget *window, *filter, *tree, *text_view; +static GtkListStore *store; +static GtkTreeModel *filtered; +static GtkTreeSelection *selection; +static int action_code, monitor_mode, line_count; +static char *selected_cache; + +static void action(GtkButton *button, gpointer code) { + (void)button; + if (!action_code) action_code = GPOINTER_TO_INT(code); +} +static gboolean deleted(GtkWidget *widget, GdkEvent *event, gpointer data) { + (void)widget; (void)event; (void)data; + if (!action_code) action_code = CLOSE; + return TRUE; +} +static gboolean key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) { + (void)widget; (void)data; + if (event->keyval == GDK_KEY_Escape) { action_code = CLOSE; return TRUE; } + if (monitor_mode || !filter || !gtk_widget_has_focus(tree)) return FALSE; + if (event->keyval == GDK_KEY_BackSpace) { + const char *current = gtk_entry_get_text(GTK_ENTRY(filter)); + glong length = g_utf8_strlen(current, -1); + if (length > 0) { + char *shorter = g_utf8_substring(current, 0, length - 1); + gtk_entry_set_text(GTK_ENTRY(filter), shorter); + gtk_editable_set_position(GTK_EDITABLE(filter), -1); + g_free(shorter); + return TRUE; + } + return FALSE; + } + if (event->state & (GDK_CONTROL_MASK | GDK_MOD1_MASK | GDK_SUPER_MASK | GDK_META_MASK)) return FALSE; + gunichar ch = gdk_keyval_to_unicode(event->keyval); + if (!ch || !g_unichar_isprint(ch)) return FALSE; + char utf8[7] = {0}; + g_unichar_to_utf8(ch, utf8); + char *updated = g_strconcat(gtk_entry_get_text(GTK_ENTRY(filter)), utf8, NULL); + gtk_entry_set_text(GTK_ENTRY(filter), updated); + gtk_editable_set_position(GTK_EDITABLE(filter), -1); + gtk_widget_grab_focus(filter); + g_free(updated); + return TRUE; +} +static gboolean visible(GtkTreeModel *model, GtkTreeIter *iter, gpointer data) { + (void)data; + char *query = g_strstrip(g_strdup(gtk_entry_get_text(GTK_ENTRY(filter)))); + if (!query[0]) { g_free(query); return TRUE; } + char *value = NULL; + gtk_tree_model_get(model, iter, 0, &value, -1); + char *folded_value = g_utf8_casefold(value ? value : "", -1); + char *folded_query = g_utf8_casefold(query, -1); + gboolean match = g_str_has_prefix(folded_value, folded_query); + g_free(value); g_free(query); g_free(folded_value); g_free(folded_query); + return match; +} +static void filter_changed(GtkEditable *editable, gpointer data) { + (void)editable; (void)data; + gtk_tree_model_filter_refilter(GTK_TREE_MODEL_FILTER(filtered)); + GtkTreeIter first; + if (gtk_tree_model_get_iter_first(filtered, &first)) { + GtkTreePath *path = gtk_tree_model_get_path(filtered, &first); + gtk_tree_selection_select_path(selection, path); + gtk_tree_path_free(path); + } else gtk_tree_selection_unselect_all(selection); +} +static GtkWidget *button(GtkWidget *box, const char *label, int code) { + GtkWidget *widget = gtk_button_new_with_label(label); + g_signal_connect(widget, "clicked", G_CALLBACK(action), GINT_TO_POINTER(code)); + gtk_box_pack_start(GTK_BOX(box), widget, FALSE, FALSE, 0); + return widget; +} +int history_init(int monitor) { + if (!gtk_init_check(NULL, NULL)) return 0; + monitor_mode = monitor; + line_count = 0; + g_set_application_name(monitor ? "Cthulhu Speech Monitor" : "Cthulhu Speech History"); + window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window), monitor ? "Speech Monitor - Cthulhu" : "Speech History - Cthulhu"); + gtk_window_set_default_size(GTK_WINDOW(window), monitor ? 900 : 700, monitor ? 320 : 420); + gtk_container_set_border_width(GTK_CONTAINER(window), 10); + if (monitor) { + gtk_window_set_keep_above(GTK_WINDOW(window), TRUE); + gtk_window_set_focus_on_map(GTK_WINDOW(window), FALSE); + gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_UTILITY); + } else gtk_window_set_modal(GTK_WINDOW(window), TRUE); + g_signal_connect(window, "delete-event", G_CALLBACK(deleted), NULL); + g_signal_connect(window, "key-press-event", G_CALLBACK(key_press), NULL); + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_container_add(GTK_CONTAINER(window), box); + if (!monitor) { + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget *label = gtk_label_new_with_mnemonic("_Filter:"); + filter = gtk_entry_new(); + gtk_widget_set_hexpand(filter, TRUE); + gtk_label_set_mnemonic_widget(GTK_LABEL(label), filter); + gtk_box_pack_start(GTK_BOX(row), label, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(row), filter, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(box), row, FALSE, FALSE, 0); + store = gtk_list_store_new(1, G_TYPE_STRING); + filtered = gtk_tree_model_filter_new(GTK_TREE_MODEL(store), NULL); + gtk_tree_model_filter_set_visible_func(GTK_TREE_MODEL_FILTER(filtered), visible, NULL, NULL); + g_signal_connect(filter, "changed", G_CALLBACK(filter_changed), NULL); + } + GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_widget_set_vexpand(scroll, TRUE); + gtk_box_pack_start(GTK_BOX(box), scroll, TRUE, TRUE, 0); + if (monitor) { + text_view = gtk_text_view_new(); + gtk_text_view_set_editable(GTK_TEXT_VIEW(text_view), FALSE); + gtk_text_view_set_cursor_visible(GTK_TEXT_VIEW(text_view), FALSE); + gtk_widget_set_can_focus(text_view, FALSE); + gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(text_view), GTK_WRAP_WORD_CHAR); + AtkObject *accessible = gtk_widget_get_accessible(text_view); + if (accessible) atk_object_set_name(accessible, "Speech monitor output"); + gtk_container_add(GTK_CONTAINER(scroll), text_view); + } else { + tree = gtk_tree_view_new_with_model(filtered); + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + g_object_set(renderer, "wrap-width", 640, "wrap-mode", PANGO_WRAP_WORD_CHAR, NULL); + GtkTreeViewColumn *column = gtk_tree_view_column_new_with_attributes("Spoken Text", renderer, "text", 0, NULL); + gtk_tree_view_column_set_expand(column, TRUE); + gtk_tree_view_column_set_resizable(column, TRUE); + gtk_tree_view_append_column(GTK_TREE_VIEW(tree), column); + selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree)); + gtk_tree_selection_set_mode(selection, GTK_SELECTION_SINGLE); + gtk_container_add(GTK_CONTAINER(scroll), tree); + } + GtkWidget *buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_halign(buttons, GTK_ALIGN_END); + gtk_box_pack_start(GTK_BOX(box), buttons, FALSE, FALSE, 0); + if (monitor) button(buttons, "Clear", CLEAR); + else { + button(buttons, "Copy to clipboard", COPY); + button(buttons, "Remove from history", REMOVE); + } + button(buttons, "Close", CLOSE); + gtk_widget_show_all(window); + if (!monitor) gtk_widget_grab_focus(filter); + return 1; +} +void history_set_items(char **items, int count, int selected) { + if (!store) return; + gtk_list_store_clear(store); + for (int i = 0; i < count; i++) { + GtkTreeIter iter; + gtk_list_store_append(store, &iter); + gtk_list_store_set(store, &iter, 0, items[i], -1); + } + if (count > 0) { + GtkTreePath *path = gtk_tree_path_new_from_indices(selected >= 0 && selected < count ? selected : 0, -1); + gtk_tree_selection_select_path(selection, path); + gtk_tree_path_free(path); + gtk_widget_grab_focus(tree); + } else gtk_widget_grab_focus(filter); +} +void history_append(const char *text) { + if (!text_view || !text) return; + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(text_view)); + GtkTextIter end; + gtk_text_buffer_get_end_iter(buffer, &end); + gtk_text_buffer_insert(buffer, &end, text, -1); + gtk_text_buffer_insert(buffer, &end, "\n", 1); + if (++line_count > 500) { + GtkTextIter start, cut; + gtk_text_buffer_get_start_iter(buffer, &start); + gtk_text_buffer_get_iter_at_line(buffer, &cut, line_count - 500); + gtk_text_buffer_delete(buffer, &start, &cut); + line_count = 500; + } + gtk_text_buffer_get_end_iter(buffer, &end); + GtkTextMark *mark = gtk_text_buffer_create_mark(buffer, NULL, &end, FALSE); + gtk_text_view_scroll_mark_onscreen(GTK_TEXT_VIEW(text_view), mark); + gtk_text_buffer_delete_mark(buffer, mark); +} +void history_present(void) { if (window) gtk_window_present(GTK_WINDOW(window)); } +void history_iterate(void) { while (gtk_events_pending()) gtk_main_iteration(); } +int history_action(void) { int result = action_code; action_code = NONE; return result; } +const char *history_selected(void) { + GtkTreeIter iter; GtkTreeModel *model = NULL; + if (!selection || !gtk_tree_selection_get_selected(selection, &model, &iter)) return ""; + g_free(selected_cache); + gtk_tree_model_get(model, &iter, 0, &selected_cache, -1); + return selected_cache ? selected_cache : ""; +} +int history_selected_row(void) { + GtkTreeIter iter; GtkTreeModel *model = NULL; + if (!selection || !gtk_tree_selection_get_selected(selection, &model, &iter)) return 0; + GtkTreePath *path = gtk_tree_model_get_path(model, &iter); + int *indices = gtk_tree_path_get_indices(path); + int row = indices ? indices[0] : 0; + gtk_tree_path_free(path); + return row; +} +int history_copy(void) { + const char *text = history_selected(); + if (!text || !text[0]) return 0; + GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + gtk_clipboard_set_text(clipboard, text, -1); + gtk_clipboard_store(clipboard); + return 1; +} +void history_clear(void) { + if (!text_view) return; + gtk_text_buffer_set_text(gtk_text_view_get_buffer(GTK_TEXT_VIEW(text_view)), "", 0); + line_count = 0; +} +void history_close(void) { + if (window) gtk_widget_destroy(window); + window = filter = tree = text_view = NULL; + store = NULL; filtered = NULL; selection = NULL; + g_free(selected_cache); selected_cache = NULL; +} diff --git a/internal/speechhistoryui/native.h b/internal/speechhistoryui/native.h new file mode 100644 index 0000000..66d1615 --- /dev/null +++ b/internal/speechhistoryui/native.h @@ -0,0 +1,14 @@ +#ifndef CTHULHU_SPEECH_HISTORY_UI_H +#define CTHULHU_SPEECH_HISTORY_UI_H +int history_init(int monitor); +void history_set_items(char **items, int count, int selected); +void history_append(const char *text); +void history_present(void); +void history_iterate(void); +int history_action(void); +const char *history_selected(void); +int history_selected_row(void); +int history_copy(void); +void history_clear(void); +void history_close(void); +#endif diff --git a/internal/speechhistoryui/ui.go b/internal/speechhistoryui/ui.go new file mode 100644 index 0000000..0f20886 --- /dev/null +++ b/internal/speechhistoryui/ui.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package speechhistoryui + +/* +#cgo pkg-config: gtk+-3.0 +#include +#include "native.h" +*/ +import "C" + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "runtime" + "time" + "unsafe" +) + +type Message struct { + Mode string `json:"mode,omitempty"` + Items []string `json:"items"` + Selected int `json:"selected,omitempty"` + Append string `json:"append,omitempty"` + Present bool `json:"present,omitempty"` + Close bool `json:"close,omitempty"` +} + +type Request struct { + Action string `json:"action"` + Selection string `json:"selection,omitempty"` + Row int `json:"row,omitempty"` +} + +// Run keeps GTK on one OS thread and exchanges bounded messages with Cthulhu. +func Run(ctx context.Context, input io.Reader, output io.Writer) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + updates := make(chan Message, 16) + readErrors := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 4096), 1024*1024) + for scanner.Scan() { + var update Message + if err := json.Unmarshal(scanner.Bytes(), &update); err != nil { + readErrors <- err + return + } + select { + case updates <- update: + case <-ctx.Done(): + return + } + } + readErrors <- scanner.Err() + }() + var initial Message + select { + case initial = <-updates: + case err := <-readErrors: + return err + case <-ctx.Done(): + return ctx.Err() + } + monitor := initial.Mode == "monitor" + mode := C.int(0) + if monitor { + mode = 1 + } + if C.history_init(mode) == 0 { + return errors.New("cannot open speech history on this display") + } + defer C.history_close() + if !monitor { + setItems(initial.Items, initial.Selected) + } + encoder := json.NewEncoder(output) + // AT-SPI requests are served by GTK's main context. A longer polling + // interval is paid once for every synchronous accessibility query, so a + // single arrow key can accumulate hundreds of milliseconds of delay. + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-readErrors: + return err + case update := <-updates: + if update.Close { + return nil + } + if update.Present { + C.history_present() + } + if update.Items != nil { + setItems(update.Items, update.Selected) + } + if update.Append != "" { + value := C.CString(update.Append) + C.history_append(value) + C.free(unsafe.Pointer(value)) + } + case <-ticker.C: + C.history_iterate() + switch C.history_action() { + case 1: + if C.history_copy() != 0 { + if err := encoder.Encode(Request{Action: "copy"}); err != nil { + return err + } + } else if err := encoder.Encode(Request{Action: "empty"}); err != nil { + return err + } + case 2: + if err := encoder.Encode(Request{Action: "remove", Selection: C.GoString(C.history_selected()), Row: int(C.history_selected_row())}); err != nil { + return err + } + case 3: + return nil + case 4: + C.history_clear() + } + } + } +} + +func setItems(items []string, selected int) { + values := make([]*C.char, len(items)) + for i, item := range items { + values[i] = C.CString(item) + } + defer func() { + for _, value := range values { + C.free(unsafe.Pointer(value)) + } + }() + if len(values) == 0 { + C.history_set_items(nil, 0, 0) + return + } + C.history_set_items((**C.char)(unsafe.Pointer(&values[0])), C.int(len(values)), C.int(selected)) +} diff --git a/internal/tentacle/bindings.go b/internal/tentacle/bindings.go new file mode 100644 index 0000000..7d8678d --- /dev/null +++ b/internal/tentacle/bindings.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import "git.stormux.org/storm/cthulhu/internal/input" + +// Binding proposes a default. It does not acquire a native grab. Scope follows +// the existing global/browse rule: neither may shadow the other in one layout. +type Binding struct { + Layout string `json:"layout"` + Scope string `json:"scope"` + Key string `json:"key"` +} + +type BindingStatus struct { + Command string + Suggested Binding + Bound bool + Conflict string // Existing command ID; empty when there is no conflict. +} + +func binding_layout(name string) input.KeyboardLayout { + if name == "desktop" { + return input.DesktopKeyboardLayout + } + return input.LaptopKeyboardLayout +} + +func validate_binding(binding Binding) error { + if (binding.Layout != "desktop" && binding.Layout != "laptop") || + (binding.Scope != "global" && binding.Scope != "browse") || len(binding.Key) > 128 { + return ErrProtocol + } + if _, err := input.ParseCommandChord(binding.Key); err != nil { + return ErrProtocol + } + return nil +} + +func (session *Session) Bindings() []BindingStatus { + session.mutex.Lock() + defer session.mutex.Unlock() + return append([]BindingStatus(nil), session.bindings...) +} + +// Caller holds manager.mutex, but no session lock. Revoked sessions immediately +// stop reserving chords even while their processes are still shutting down. +func (manager *Manager) occupied_bindings() map[input.KeyboardLayout]input.CommandBindings { + occupied := make(map[input.KeyboardLayout]input.CommandBindings) + for layout, bindings := range manager.coreBindings { + occupied[layout] = make(input.CommandBindings) + for chord, command := range bindings { + occupied[layout][chord] = command + } + } + for _, session := range manager.sessions { + session.mutex.Lock() + if session.state == Active { + for _, binding := range session.bindings { + if binding.Bound { + chord, _ := input.ParseCommandChord(binding.Suggested.Key) + occupied[binding_layout(binding.Suggested.Layout)][chord] = input.Command(binding.Command) + } + } + } + session.mutex.Unlock() + } + return occupied +} + +func resolve_bindings(identity string, commands []Command, occupied map[input.KeyboardLayout]input.CommandBindings) []BindingStatus { + var result []BindingStatus + for _, command := range commands { + commandID := "tentacle/" + identity + "/" + command.ID + for _, binding := range command.Bindings { + chord, _ := input.ParseCommandChord(binding.Key) + bindings := occupied[binding_layout(binding.Layout)] + previous := bindings[chord] + result = append(result, BindingStatus{Command: commandID, Suggested: binding, + Bound: previous == "", Conflict: string(previous)}) + if previous == "" { + bindings[chord] = input.Command(commandID) + } + } + } + return result +} diff --git a/internal/tentacle/containment.go b/internal/tentacle/containment.go new file mode 100644 index 0000000..89935cc --- /dev/null +++ b/internal/tentacle/containment.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "syscall" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +var ErrContainment = errors.New("tentacle resource containment failed") + +// ResourceBudget includes the child and all its descendants. Tasks counts +// kernel threads as well as processes. CPUQuota is microseconds per 100 ms, +// so 100000 allows one CPU, independent of the machine's number of CPUs. +type ResourceBudget struct { + CPUQuota int64 + MemoryBytes int64 + Tasks int64 +} + +type ResourceLimits struct { + PerSession ResourceBudget + Total ResourceBudget + CleanupTimeout time.Duration +} + +func DefaultResourceLimits() ResourceLimits { + return ResourceLimits{ + PerSession: ResourceBudget{CPUQuota: 25000, MemoryBytes: 256 << 20, Tasks: 64}, + Total: ResourceBudget{CPUQuota: 100000, MemoryBytes: 1 << 30, Tasks: 256}, + CleanupTimeout: 2 * time.Second, + } +} + +func (limits ResourceLimits) validate() error { + for _, budget := range []ResourceBudget{limits.PerSession, limits.Total} { + if budget.CPUQuota < 1000 || budget.MemoryBytes < 1<<20 || budget.Tasks < 1 { + return fmt.Errorf("%w: invalid resource budget", ErrContainment) + } + } + if limits.Total.CPUQuota < limits.PerSession.CPUQuota || + limits.Total.MemoryBytes < limits.PerSession.MemoryBytes || + limits.Total.Tasks < limits.PerSession.Tasks || limits.CleanupTimeout <= 0 { + return fmt.Errorf("%w: invalid aggregate budget or cleanup deadline", ErrContainment) + } + return nil +} + +// NewContainedManager creates a private subtree in an already delegated cgroup +// v2 parent with cpu, memory and pids enabled in cgroup.subtree_control. It never +// moves the host or changes the caller's parent. Setup may perform filesystem +// I/O and belongs on a lifecycle worker, never an input callback. +// +// There is no fallback when delegation, controllers, cgroup.kill or atomic +// cgroup placement are unavailable. This is resource containment for trusted +// local code, not a security sandbox against same-user cgroup modification, +// migration, privileged scheduling, or external service launches. +func NewContainedManager(limits Limits, overrides []input.BindingOverride, parent string, resources ResourceLimits) (*Manager, error) { + if err := resources.validate(); err != nil { + return nil, err + } + manager, err := NewManager(limits, overrides) + if err != nil { + return nil, err + } + if !filepath.IsAbs(parent) { + return nil, fmt.Errorf("%w: cgroup parent must be absolute", ErrContainment) + } + var stat syscall.Statfs_t + if err := syscall.Statfs(parent, &stat); err != nil || stat.Type != 0x63677270 { + return nil, fmt.Errorf("%w: parent is not a cgroup v2 filesystem", ErrContainment) + } + var identity [16]byte + if _, err := rand.Read(identity[:]); err != nil { + return nil, err + } + group, err := create_cgroup(filepath.Join(parent, fmt.Sprintf("cthulhu-tentacles-%x", identity)), resources.Total, limits.Processes) + if err != nil { + return nil, err + } + if err := write_control(group.path, "cgroup.subtree_control", "+cpu +memory +pids"); err != nil { + return nil, errors.Join(err, group.remove()) + } + manager.containment = &containment{group: group, limits: resources} + return manager, nil +} + +type containment struct { + group *cgroup + limits ResourceLimits +} + +type cgroup struct { + path string + file *os.File +} + +func write_control(path, name, value string) error { + // Do not create ordinary files if the kernel lacks a required controller. + file, err := os.OpenFile(filepath.Join(path, name), os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("%w: open %s: %w", ErrContainment, name, err) + } + _, writeError := file.WriteString(value) + return errors.Join(wrap_control_error(name, writeError), wrap_control_error(name, file.Close())) +} + +func wrap_control_error(name string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%w: %s: %w", ErrContainment, name, err) +} + +func create_cgroup(path string, budget ResourceBudget, children int) (*cgroup, error) { + if err := os.Mkdir(path, 0700); err != nil { + return nil, fmt.Errorf("%w: create group: %w", ErrContainment, err) + } + group := &cgroup{path: path} + settings := []struct{ name, value string }{ + {"cpu.max", strconv.FormatInt(budget.CPUQuota, 10) + " 100000"}, + {"memory.max", strconv.FormatInt(budget.MemoryBytes, 10)}, + {"memory.swap.max", "0"}, + {"pids.max", strconv.FormatInt(budget.Tasks, 10)}, + {"cgroup.max.descendants", strconv.Itoa(children)}, + } + if children == 0 { + // If any task exhausts memory, retire the entire session, not an + // arbitrary worker while leaving a partially broken child alive. + settings = append(settings, struct{ name, value string }{"memory.oom.group", "1"}) + } + for _, setting := range settings { + if err := write_control(path, setting.name, setting.value); err != nil { + return nil, errors.Join(err, group.remove()) + } + } + // Check availability without issuing a kill before launch. Some kernels + // SIGKILL CLONE_INTO_CGROUP children entering a previously killed group. + killFile, err := os.OpenFile(filepath.Join(path, "cgroup.kill"), os.O_WRONLY, 0) + if err != nil { + return nil, errors.Join(wrap_control_error("open cgroup.kill", err), group.remove()) + } + if err := killFile.Close(); err != nil { + return nil, errors.Join(wrap_control_error("close cgroup.kill", err), group.remove()) + } + file, err := os.Open(path) + if err != nil { + return nil, errors.Join(wrap_control_error("open group", err), group.remove()) + } + group.file = file + return group, nil +} + +func (group *cgroup) remove() error { + if group.file != nil { + _ = group.file.Close() + group.file = nil + } + return wrap_control_error("remove group", os.Remove(group.path)) +} + +// kill_and_remove waits only during teardown. Successful removal proves the +// kernel no longer has live tasks in the group, including setsid descendants. +func (group *cgroup) kill_and_remove(timeout time.Duration) error { + defer func() { + if group.file != nil { + _ = group.file.Close() + group.file = nil + } + }() + if err := write_control(group.path, "cgroup.kill", "1"); err != nil { + return err + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + err := group.remove() + if err == nil || !errors.Is(err, syscall.EBUSY) { + return err + } + select { + case <-ticker.C: + case <-deadline.C: + return fmt.Errorf("%w: cleanup deadline expired", ErrContainment) + } + } +} diff --git a/internal/tentacle/containment_test.go b/internal/tentacle/containment_test.go new file mode 100644 index 0000000..0b9a06e --- /dev/null +++ b/internal/tentacle/containment_test.go @@ -0,0 +1,386 @@ +package tentacle + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestContainmentRejectsInvalidConfiguration(t *testing.T) { + for _, change := range []func(*ResourceLimits){ + func(limits *ResourceLimits) { limits.PerSession.CPUQuota = 0 }, + func(limits *ResourceLimits) { limits.PerSession.MemoryBytes = 0 }, + func(limits *ResourceLimits) { limits.PerSession.Tasks = 0 }, + func(limits *ResourceLimits) { limits.Total.CPUQuota = limits.PerSession.CPUQuota - 1 }, + func(limits *ResourceLimits) { limits.Total.MemoryBytes = limits.PerSession.MemoryBytes - 1 }, + func(limits *ResourceLimits) { limits.Total.Tasks = limits.PerSession.Tasks - 1 }, + func(limits *ResourceLimits) { limits.CleanupTimeout = 0 }, + } { + limits := DefaultResourceLimits() + change(&limits) + if _, err := NewContainedManager(DefaultLimits(), nil, t.TempDir(), limits); !errors.Is(err, ErrContainment) { + t.Fatalf("invalid budget accepted: %v", err) + } + } + // Ordinary directories must never be treated as working cgroup controllers. + parent := t.TempDir() + if _, err := NewContainedManager(DefaultLimits(), nil, parent, DefaultResourceLimits()); !errors.Is(err, ErrContainment) { + t.Fatalf("ordinary directory accepted: %v", err) + } + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 0 { + t.Fatalf("failed setup mutated ordinary parent: %v %v", entries, err) + } +} + +func contained_manager(t *testing.T, resources ResourceLimits) *Manager { + t.Helper() + parent := os.Getenv("CTHULHU_TENTACLE_CGROUP_PARENT") + if parent == "" { + t.Skip("set CTHULHU_TENTACLE_CGROUP_PARENT to an empty delegated cgroup v2 parent with cpu/memory/pids enabled") + } + limits := DefaultLimits() + limits.RequestTimeout = 4 * time.Second + manager, err := NewContainedManager(limits, nil, parent, resources) + if err != nil { + t.Fatal(err) // An explicitly requested native check must not silently skip. + } + groupPath := manager.containment.group.path + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := manager.Shutdown(ctx); err != nil { + t.Error(err) + } + if stats := manager.Stats(); stats != (Stats{}) { + t.Errorf("retained resources: %+v", stats) + } + if _, err := os.Stat(groupPath); !os.IsNotExist(err) { + t.Errorf("aggregate group remains: %v", err) + } + if err := manager.Shutdown(ctx); err != nil { + t.Errorf("repeated shutdown: %v", err) + } + }) + return manager +} + +func resource_peer(t *testing.T, manager *Manager, id string) *Session { + t.Helper() + python, err := exec.LookPath("python3") + if err != nil { + t.Fatal(err) + } + fixture, err := filepath.Abs("testdata/resource_peer.py") + if err != nil { + t.Fatal(err) + } + session, err := manager.Start(context.Background(), Spec{ID: id, Program: []string{python, "-I", "-u", fixture}}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if state, err := session.Status(); state != Ready || err != nil { + t.Fatalf("resource peer %s: %v", state, err) + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + return session +} + +func resource_call(t *testing.T, session *Session, arguments string) Result { + t.Helper() + pending, err := session.Invoke(context.Background(), "echo", json.RawMessage(arguments)) + if err != nil { + t.Fatal(err) + } + return wait_result(t, pending) +} + +func read_control(t *testing.T, path, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(path, name)) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(data)) +} + +func control_counter(t *testing.T, path, name, key string) int64 { + t.Helper() + for _, line := range strings.Split(read_control(t, path, name), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == key { + value, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + t.Fatal(err) + } + return value + } + } + t.Fatalf("missing %s counter in %s", key, name) + return 0 +} + +func wait_control(t *testing.T, check func() bool) { + t.Helper() + deadline := time.NewTimer(4 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for !check() { + select { + case <-ticker.C: + case <-deadline.C: + t.Fatal("kernel resource condition was not observed") + } + } +} + +func TestNativeContainmentPlacementAndDetachedCleanup(t *testing.T) { + manager := contained_manager(t, DefaultResourceLimits()) + session := resource_peer(t, manager, "detaching") + groupPath := filepath.Join(manager.containment.group.path, session.Identity()) + for name, expected := range map[string]string{ + "cpu.max": "25000 100000", "memory.max": "268435456", "memory.swap.max": "0", + "pids.max": "64", "memory.oom.group": "1", "cgroup.max.descendants": "0", + } { + if actual := read_control(t, groupPath, name); actual != expected { + t.Fatalf("%s = %s, want %s", name, actual, expected) + } + } + result := resource_call(t, session, `{}`) + var location string + if result.Err != nil || json.Unmarshal(result.Value, &location) != nil || !strings.HasSuffix(location, "/"+session.Identity()) { + t.Fatalf("child ran outside its group: %+v", result) + } + result = resource_call(t, session, `{"action":"detach"}`) + var pid int + if result.Err != nil || json.Unmarshal(result.Value, &pid) != nil || pid <= 1 { + t.Fatalf("missing detached child: %+v", result) + } + if actual := read_control(t, fmt.Sprintf("/proc/%d", pid), "cgroup"); actual != location { + t.Fatalf("detached descendant escaped: %s", actual) + } + session.Disable() + wait_signal(t, session.Done()) + if state, err := session.Status(); state != Stopped || err != nil { + t.Fatalf("cleanup %s: %v", state, err) + } + if _, err := os.Stat(groupPath); !os.IsNotExist(err) { + t.Fatalf("session group survived Done: %v", err) + } + // Kernel exit can remove a task from its cgroup just before /proc shows Z. + wait_control(t, func() bool { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if os.IsNotExist(err) { + return true + } + if err != nil { + t.Fatal(err) + } + _, tail, found := strings.Cut(string(data), ") ") + return found && strings.HasPrefix(tail, "Z ") + }) + // A new identity can reuse capacity after all descendants are gone. + resource_peer(t, manager, "detaching") +} + +func TestNativeContainmentExec(t *testing.T) { + manager := contained_manager(t, DefaultResourceLimits()) + group, err := create_cgroup(filepath.Join(manager.containment.group.path, "exec-proof"), DefaultResourceLimits().PerSession, 0) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := group.kill_and_remove(time.Second); err != nil { + t.Error(err) + } + }() + python, err := exec.LookPath("python3") + if err != nil { + t.Fatal(err) + } + fixture, err := filepath.Abs("testdata/resource_peer.py") + if err != nil { + t.Fatal(err) + } + command := exec.Command(python, "-I", "-u", fixture) + command.SysProcAttr = &syscall.SysProcAttr{UseCgroupFD: true, CgroupFD: int(group.file.Fd())} + command.Stdin = strings.NewReader(`{"id":"1","method":"initialize","params":{"protocol":{"major":1,"minor":0},"session":"test"}}` + "\n" + `{"method":"shutdown"}` + "\n") + output, err := command.CombinedOutput() + if err != nil || !strings.Contains(string(output), `"result"`) { + t.Fatalf("private fixture exec: %v: %s", err, output) + } +} + +func TestNativeContainmentTaskLimits(t *testing.T) { + resources := DefaultResourceLimits() + resources.PerSession.Tasks, resources.Total.Tasks = 8, 12 + manager := contained_manager(t, resources) + first := resource_peer(t, manager, "first") + second := resource_peer(t, manager, "second") + for _, session := range []*Session{first, second} { + if result := resource_call(t, session, `{"action":"tasks"}`); result.Err != nil { + t.Fatal(result.Err) + } + } + parent := manager.containment.group.path + if count := read_control(t, parent, "pids.current"); count != "12" { + t.Fatalf("aggregate task limit was not reached: %s", count) + } + if count := read_control(t, filepath.Join(parent, first.Identity()), "pids.current"); count != "8" { + t.Fatalf("session task limit was not reached: %s", count) + } + if control_counter(t, parent, "pids.events", "max") == 0 { + t.Fatal("kernel did not report task limit enforcement") + } +} + +func TestNativeContainmentMemoryLimits(t *testing.T) { + for _, aggregate := range []bool{false, true} { + t.Run(fmt.Sprintf("aggregate-%t", aggregate), func(t *testing.T) { + resources := DefaultResourceLimits() + resources.PerSession.MemoryBytes, resources.Total.MemoryBytes = 64<<20, 96<<20 + manager := contained_manager(t, resources) + first := resource_peer(t, manager, "first") + if aggregate { + second := resource_peer(t, manager, "second") + if result := resource_call(t, first, `{"action":"memory","bytes":50331648}`); result.Err != nil { + t.Fatal(result.Err) + } + // The kernel may select either session as the aggregate OOM victim. + resource_call(t, second, `{"action":"memory","bytes":50331648}`) + } else { + if result := resource_call(t, first, `{"action":"memory","bytes":100663296}`); result.Err == nil { + t.Fatal("allocation above session limit succeeded") + } + wait_signal(t, first.Done()) + } + wait_control(t, func() bool { + return control_counter(t, manager.containment.group.path, "memory.events", "oom_kill") > 0 + }) + }) + } +} + +func TestNativeContainmentCPUThrottling(t *testing.T) { + for _, aggregate := range []bool{false, true} { + t.Run(fmt.Sprintf("aggregate-%t", aggregate), func(t *testing.T) { + resources := DefaultResourceLimits() + if aggregate { + resources.PerSession.CPUQuota, resources.Total.CPUQuota = 50000, 50000 + } + manager := contained_manager(t, resources) + first := resource_peer(t, manager, "first") + path := filepath.Join(manager.containment.group.path, first.Identity()) + if aggregate { + second := resource_peer(t, manager, "second") + if result := resource_call(t, second, `{"action":"cpu"}`); result.Err != nil { + t.Fatal(result.Err) + } + path = manager.containment.group.path + } + if result := resource_call(t, first, `{"action":"cpu"}`); result.Err != nil { + t.Fatal(result.Err) + } + wait_control(t, func() bool { return control_counter(t, path, "cpu.stat", "nr_throttled") > 0 }) + }) + } +} + +func TestNativeContainmentFailedLaunchReleasesGroups(t *testing.T) { + manager := contained_manager(t, DefaultResourceLimits()) + session, err := manager.Start(context.Background(), Spec{ID: "missing", Program: []string{"/nonexistent/tentacle-fixture"}}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Done()) + if state, err := session.Status(); state != Failed || err == nil { + t.Fatalf("missing executable %s: %v", state, err) + } + if _, err := os.Stat(filepath.Join(manager.containment.group.path, session.Identity())); !os.IsNotExist(err) { + t.Fatalf("failed launch leaked a group: %v", err) + } + resource_peer(t, manager, "missing") +} + +func TestNativeContainmentSDKRoundTrip(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + manager := contained_manager(t, DefaultResourceLimits()) + program := sdk_program(t, language, filepath.Join(t.TempDir(), "cancelled")) + session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: program}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if state, err := session.Status(); state != Ready || err != nil { + t.Fatalf("contained SDK startup %s: %v", state, err) + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + const arguments = `{"text":"é猫😀é"}` + if result := resource_call(t, session, arguments); result.Err != nil || string(result.Value) != arguments { + t.Fatalf("contained SDK round trip: %+v", result) + } + }) + } +} + +func TestNativeContainmentCleanupFailure(t *testing.T) { + manager := contained_manager(t, DefaultResourceLimits()) + session := resource_peer(t, manager, "cleanup-failure") + groupPath := filepath.Join(manager.containment.group.path, session.Identity()) + killPath := filepath.Join(groupPath, "cgroup.kill") + info, err := os.Stat(killPath) + if err != nil { + t.Fatal(err) + } + // This peer has no descendants. Remove kill-file access to exercise the + // real cleanup error; direct child termination still joins the pipe workers. + if err := os.Chmod(killPath, 0); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chmod(killPath, info.Mode().Perm()); err != nil { + t.Error(err) + return + } + if err := (&cgroup{path: groupPath}).kill_and_remove(time.Second); err != nil { + t.Error(err) + return + } + // Only the fixture repairs its deliberately broken group; production + // requires supervisor recovery and never reclaims an uncertain slot. + manager.mutex.Lock() + delete(manager.sessions, session.spec.ID) + manager.cleanupError = nil + manager.mutex.Unlock() + }() + session.Disable() + wait_signal(t, session.Done()) + if state, err := session.Status(); state != Failed || !errors.Is(err, ErrContainment) { + t.Fatalf("failed cleanup reported %s: %v", state, err) + } + if manager.Stats().Processes != 1 { + t.Fatal("failed cleanup released admission capacity") + } + if _, err := manager.Start(context.Background(), child_spec(t, "blocked", "echo")); !errors.Is(err, ErrContainment) { + t.Fatalf("cleanup failure admitted a child: %v", err) + } + if err := manager.Shutdown(context.Background()); !errors.Is(err, ErrContainment) { + t.Fatalf("cleanup failure reported successful shutdown: %v", err) + } +} diff --git a/internal/tentacle/delegation.go b/internal/tentacle/delegation.go new file mode 100644 index 0000000..2173dd2 --- /dev/null +++ b/internal/tentacle/delegation.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "bufio" + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// Delegation owns a transient user service, not the reader process. Its small +// guardian exits on pipe EOF, including reader death; systemd then kills the +// whole service subtree. No service or example is installed or enabled on disk. +type Delegation struct { + Parent string + input io.WriteCloser + output io.ReadCloser + done chan struct{} + waitError error + once sync.Once + unit string + directory string + process *os.Process +} + +func StartDelegation(ctx context.Context, program []string) (*Delegation, error) { + if len(program) == 0 || !filepath.IsAbs(program[0]) { + return nil, ErrContainment + } + var identity [16]byte + if _, err := rand.Read(identity[:]); err != nil { + return nil, err + } + unit := fmt.Sprintf("cthulhu-tentacles-%x.service", identity) + directory, err := os.MkdirTemp("", "cthulhu-tentacles-") + if err != nil { + return nil, err + } + keepDirectory := false + defer func() { + if !keepDirectory { + os.Remove(filepath.Join(directory, "launch")) + os.Remove(directory) + } + }() + args := []string{"--user", "--quiet", "--wait", "--pipe", "--collect", "--unit=" + unit, + "--property=Delegate=cpu memory pids", "--property=DelegateSubgroup=reader", + "--property=KillMode=control-group", "--property=TimeoutStopSec=3s"} + for _, name := range []string{"DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", + "XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", + "LANG", "LANGUAGE", "LC_ALL", "SPEECHD_ADDRESS", "SPEECHD_SOCKET"} { + if value, ok := os.LookupEnv(name); ok { + args = append(args, "--setenv="+name+"="+value) + } + } + args = append(append(args, "--"), program...) + args = append(args, filepath.Join(directory, "launch")) + command := exec.Command("systemd-run", args...) + // No inherited terminal input or unbounded diagnostics. The handshake below + // is the only stdout message; failure becomes a bounded reader status. + input, err := command.StdinPipe() + if err != nil { + return nil, err + } + output, err := command.StdoutPipe() + if err != nil { + input.Close() + return nil, err + } + if err := command.Start(); err != nil { + input.Close() + output.Close() + return nil, err + } + delegation := &Delegation{input: input, output: output, done: make(chan struct{}), unit: unit, directory: directory, process: command.Process} + go func() { delegation.waitError = command.Wait(); close(delegation.done) }() + readDone := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(output) + scanner.Buffer(make([]byte, 4096), 4096) + if !scanner.Scan() { + readDone <- ErrContainment + return + } + parent := scanner.Text() + if !strings.HasPrefix(parent, "/sys/fs/cgroup/") || filepath.Clean(parent) != parent || filepath.Base(parent) != unit { + readDone <- ErrContainment + return + } + delegation.Parent = parent + readDone <- nil + }() + startup, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + select { + case err = <-readDone: + case <-startup.Done(): + err = startup.Err() + output.Close() + <-readDone + } + if err != nil { + cleanup, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + delegation.Close(cleanup) + return nil, fmt.Errorf("%w: could not start delegated user service", ErrContainment) + } + keepDirectory = true + return delegation, nil +} + +func (delegation *Delegation) Done() <-chan struct{} { return delegation.done } + +func (delegation *Delegation) Close(ctx context.Context) error { + delegation.once.Do(func() { delegation.input.Close() }) + select { + case <-delegation.done: + delegation.output.Close() + os.Remove(filepath.Join(delegation.directory, "launch")) + os.Remove(delegation.directory) + return delegation.waitError + case <-ctx.Done(): + // Stop only the unique service this owner created. Never stop another reader. + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + stopError := exec.CommandContext(stopCtx, "systemctl", "--user", "--no-block", "stop", delegation.unit).Run() + delegation.output.Close() + _ = delegation.process.Kill() + select { + case <-delegation.done: + case <-time.After(time.Second): + } + return errors.Join(ctx.Err(), stopError) + } +} + +// RunGuardian is a private helper. stdin is a reader-owned lifetime pipe, never +// a command protocol. The guardian has no speech, keyboard, or settings access. +func RunGuardian(input io.Reader, output io.Writer, socketPath string) error { + parent, err := DelegatedParent() + if err != nil { + return err + } + err = serve_launches(input, output, socketPath, parent) + // The reader may have died; the guardian also removes its empty private + // socket directory after the listener unlinks the socket. + if filepath.Base(socketPath) == "launch" && strings.HasPrefix(filepath.Base(filepath.Dir(socketPath)), "cthulhu-tentacles-") { + os.Remove(filepath.Dir(socketPath)) + } + return err +} diff --git a/internal/tentacle/delegation_test.go b/internal/tentacle/delegation_test.go new file mode 100644 index 0000000..ed1b3af --- /dev/null +++ b/internal/tentacle/delegation_test.go @@ -0,0 +1,99 @@ +package tentacle + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestDelegationHelper(t *testing.T) { + if len(os.Args) < 3 { + return + } + mode, path := os.Args[len(os.Args)-2], os.Args[len(os.Args)-1] + if mode == "delegation-guardian" { + if err := RunGuardian(os.Stdin, os.Stdout, path); err != nil { + os.Exit(1) + } + os.Exit(0) + } + if mode != "delegation-owner" { + return + } + executable, _ := os.Executable() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + delegation, err := StartDelegation(ctx, []string{executable, "-test.run=^TestDelegationHelper$", "--", "delegation-guardian"}) + if err != nil { + t.Fatal(err) + } + manager, err := NewDelegatedManager(delegation) + if err != nil { + t.Fatal(err) + } + session, err := manager.Start(ctx, child_spec(t, "spawn", "spawn")) + if err != nil { + t.Fatal(err) + } + select { + case <-session.Ready(): + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + result, err := session.Invoke(ctx, "echo", nil) + if err != nil { + t.Fatal(err) + } + value := <-result + if value.Err != nil { + t.Fatal(value.Err) + } + var pid int + if err := json.Unmarshal(value.Value, &pid); err != nil || pid <= 0 { + t.Fatalf("descendant: %s %v", value.Value, err) + } + if err := os.WriteFile(path, []byte(delegation.Parent), 0600); err != nil { + t.Fatal(err) + } + // Simulate abrupt reader death: no Stop, Shutdown, Close, or deferred cleanup. + os.Exit(0) +} + +func TestNativeDelegationReaderDeath(t *testing.T) { + if os.Getenv("CTHULHU_TENTACLE_MANAGED") != "1" { + t.Skip("requires a systemd user manager and cgroup v2") + } + executable, _ := os.Executable() + marker := filepath.Join(t.TempDir(), "group") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable, "-test.run=^TestDelegationHelper$", "--", "delegation-owner", marker) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("owner: %v %s", err, output) + } + parent, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(6 * time.Second) + defer timer.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(string(parent)); os.IsNotExist(err) { + return + } + select { + case <-ticker.C: + case <-timer.C: + t.Fatal("reader death left its service/descendants alive") + } + } +} diff --git a/internal/tentacle/install.go b/internal/tentacle/install.go new file mode 100644 index 0000000..31a077d --- /dev/null +++ b/internal/tentacle/install.go @@ -0,0 +1,427 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "debug/elf" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "slices" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" +) + +const maxPackageBytes = 32 << 20 +const maxExpandedBytes = 64 << 20 + +func valid_digest(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) +} + +func valid_revision(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && (len(decoded) == 20 || len(decoded) == 32) && value == strings.ToLower(value) +} + +func artifact_url(value string) bool { + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme == "https" && parsed.Hostname() != "" && parsed.User == nil && parsed.Opaque == "" && parsed.Fragment == "" && !strings.Contains(value, "#") && len(value) <= 2048 && metadata_text(value, true) +} + +func validate_releases(releases []Release) error { + if len(releases) > 16 { + return ErrLimit + } + seen := make(map[string]bool) + for _, release := range releases { + key := release.Platform + "/" + release.Architecture + if !metadata_id(release.Platform) || !metadata_id(release.Architecture) || !artifact_url(release.URL) || !valid_digest(release.SHA256) || seen[key] { + return errors.New("Invalid or duplicate package release metadata.") + } + seen[key] = true + } + return nil +} + +func PackageRoot(registryPath string) string { + return filepath.Join(filepath.Dir(registryPath), "tentacle-packages") +} + +func PackageID(repository Repository, localID string) string { + digest := sha256.Sum256([]byte(repository.URL + "\x00" + repository.ID + "\x00" + localID)) + return "pkg-" + hex.EncodeToString(digest[:]) +} + +// PreparedPackage owns unpublished files until the lifecycle owner persists its +// Entry. Once queued, the caller waits for that owner's decision before Discard; +// successful persistence calls Retain. Validation never runs package code. +type PreparedPackage struct { + Entry Entry + directory string +} + +func (prepared *PreparedPackage) Discard() error { + if prepared == nil || prepared.directory == "" { + return nil + } + err := os.RemoveAll(prepared.directory) + if err == nil { + prepared.directory = "" + } + return err +} + +func (prepared *PreparedPackage) Retain() { prepared.directory = "" } + +func PreparePackage(ctx context.Context, root string, repository Repository, localID string) (*PreparedPackage, error) { + return prepare_package(ctx, root, repository, localID, new_package_client()) +} + +func new_package_client() *http.Client { + return &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= 5 || !artifact_url(request.URL.String()) { + return errors.New("Package redirect must use HTTPS without credentials.") + } + return nil + }} +} + +func prepare_package(ctx context.Context, root string, repository Repository, localID string, client *http.Client) (_ *PreparedPackage, resultError error) { + if err := ValidateRepositories([]Repository{repository}); err != nil { + return nil, err + } + index := slices.IndexFunc(repository.Tentacles, func(value Manifest) bool { return value.ID == localID }) + if index < 0 { + return nil, errors.New("Tentacle is no longer listed in this repository.") + } + manifest := repository.Tentacles[index] + releaseIndex := slices.IndexFunc(manifest.Releases, func(value Release) bool { + return value.Platform == runtime.GOOS && value.Architecture == runtime.GOARCH + }) + if releaseIndex < 0 { + return nil, fmt.Errorf("No package is available for %s/%s. Refresh the repository after a release is published.", runtime.GOOS, runtime.GOARCH) + } + release := manifest.Releases[releaseIndex] + if !filepath.IsAbs(root) { + return nil, errors.New("Package root must be absolute.") + } + if err := os.MkdirAll(root, 0700); err != nil { + return nil, err + } + info, err := os.Lstat(root) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("Package root must be a directory, not a symlink.") + } + stage, err := os.MkdirTemp(root, ".staging-") + if err != nil { + return nil, err + } + prepared := &PreparedPackage{directory: stage} + defer func() { + if resultError != nil { + _ = prepared.Discard() + } + }() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, release.URL, nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, errors.New("Package download failed; check the connection or retry.") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, errors.New("Package download failed; the server did not return a package.") + } + if response.ContentLength > maxPackageBytes { + return nil, ErrLimit + } + archive, err := os.CreateTemp(root, ".download-") + if err != nil { + return nil, err + } + defer os.Remove(archive.Name()) + defer archive.Close() + digest := sha256.New() + count, err := io.Copy(io.MultiWriter(archive, digest), io.LimitReader(response.Body, maxPackageBytes+1)) + if err != nil { + return nil, errors.New("Package download was interrupted.") + } + if count > maxPackageBytes { + return nil, ErrLimit + } + if hex.EncodeToString(digest.Sum(nil)) != release.SHA256 { + return nil, errors.New("Package checksum does not match the catalog; nothing was installed.") + } + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return nil, err + } + if err := extract_package(ctx, archive, stage); err != nil { + return nil, err + } + data, err := read_registry(filepath.Join(stage, "tentacle.toml")) + if err != nil || data == nil { + return nil, errors.New("Package must contain a regular tentacle.toml.") + } + var packageManifest struct { + Manifest + Runtime string `toml:"runtime"` + Platforms []string `toml:"platforms"` + EntryPoint []string `toml:"entry-point"` + APIs []string `toml:"apis"` + Protocol struct { + Major int `toml:"major"` + Minor int `toml:"minor"` + } `toml:"protocol"` + } + if err := toml.Unmarshal(data, &packageManifest); err != nil { + return nil, errors.New("Package manifest is invalid TOML.") + } + if packageManifest.FormatVersion != manifest.FormatVersion || packageManifest.ID != manifest.ID || packageManifest.Version != manifest.Version || packageManifest.Name != manifest.Name || !slices.Equal(packageManifest.Capabilities, manifest.Capabilities) { + return nil, errors.New("Package identity, version or permissions disagree with the catalog; refresh the repository.") + } + if packageManifest.Runtime != "native" || !slices.Contains(packageManifest.Platforms, runtime.GOOS) || packageManifest.Protocol.Major != 1 || packageManifest.Protocol.Minor != 0 { + return nil, errors.New("Package runtime, platform or protocol is unsupported by this reader.") + } + presentation := slices.Contains(packageManifest.Capabilities, "presentation") + interactive := slices.Contains(packageManifest.Capabilities, "interaction") + expectedAPIs := []string{"commands"} + if presentation { + expectedAPIs = append(expectedAPIs, "presentation") + } + if interactive { + expectedAPIs = append(expectedAPIs, "interaction") + } + if interactive && !presentation || len(packageManifest.Capabilities) != len(expectedAPIs)-1 || !slices.Equal(packageManifest.APIs, expectedAPIs) { + return nil, errors.New("Package requires unsupported APIs or permissions.") + } + if len(packageManifest.EntryPoint) == 0 || len(packageManifest.EntryPoint) > 32 || !package_path(packageManifest.EntryPoint[0]) { + return nil, errors.New("Package entry point must be a relative file path inside the package.") + } + executable := filepath.Join(stage, filepath.FromSlash(packageManifest.EntryPoint[0])) + if err := validate_native_program(executable); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := filepath.WalkDir(stage, func(filename string, entry os.DirEntry, walkError error) error { + if walkError != nil { + return walkError + } + if entry.IsDir() { + return sync_directory(filename) + } + return nil + }); err != nil { + return nil, err + } + // Every installation gets a private immutable directory. Never overwrite an + // existing version; a crash before registry commit leaves only inert files. + final := filepath.Join(root, "package-"+strings.TrimPrefix(filepath.Base(stage), ".staging-")) + if err := os.Rename(stage, final); err != nil { + return nil, err + } + prepared.directory = final + program := slices.Clone(packageManifest.EntryPoint) + program[0] = filepath.Join(final, filepath.FromSlash(program[0])) + prepared.Entry = Entry{ID: PackageID(repository, localID), LocalID: localID, Name: manifest.Name, Version: manifest.Version, + Program: program, Directory: final, Presentation: presentation, Interaction: interactive, Repository: repository.URL, RepositoryID: repository.ID, + RepositoryRevision: repository.Revision, PackageDirectory: final, PackageSHA256: release.SHA256} + if err := ValidateEntries([]Entry{prepared.Entry}); err != nil { + return nil, err + } + if err := sync_directory(final); err != nil { + return nil, err + } + if err := sync_directory(root); err != nil { + return nil, err + } + return prepared, nil +} + +func package_path(filename string) bool { + return filename != "" && filename != "." && !strings.HasPrefix(filename, "/") && path.Clean(filename) == filename && filename != ".." && !strings.HasPrefix(filename, "../") && !strings.ContainsAny(filename, "\\\x00\r\n:") && len(filename) <= 4096 +} + +func extract_package(ctx context.Context, archive io.Reader, directory string) error { + compressed, err := gzip.NewReader(archive) + if err != nil { + return errors.New("Package must be a gzip tar archive.") + } + defer compressed.Close() + limited := &io.LimitedReader{R: compressed, N: maxExpandedBytes + 1} + reader := tar.NewReader(limited) + seen := make(map[string]bool) + for count := 0; ; count++ { + if err := ctx.Err(); err != nil { + return err + } + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return errors.New("Invalid or oversized package archive.") + } + if count >= 1024 || header.Size < 0 || header.Size > maxExpandedBytes { + return ErrLimit + } + filename := strings.TrimSuffix(header.Name, "/") + if !package_path(filename) || seen[filename] { + return errors.New("Package contains unsafe or duplicate paths.") + } + seen[filename] = true + target := filepath.Join(directory, filepath.FromSlash(filename)) + switch header.Typeflag { + case tar.TypeDir: + if header.Size != 0 { + return errors.New("Invalid package directory.") + } + if err := os.MkdirAll(target, 0700); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + mode := os.FileMode(0600) + if header.Mode&0111 != 0 { + mode = 0700 + } + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return errors.New("Package contains conflicting paths.") + } + _, copyError := io.CopyN(file, reader, header.Size) + syncError := file.Sync() + closeError := file.Close() + if err := errors.Join(copyError, syncError, closeError); err != nil { + return err + } + default: + return errors.New("Packages may contain only regular files and directories, without links or devices.") + } + } + // Read through the gzip trailer to detect corruption and expansion bombs. + if _, err := io.Copy(io.Discard, limited); err != nil { + return errors.New("Package compression is corrupt.") + } + if limited.N == 0 { + return ErrLimit + } + return nil +} + +func validate_native_program(filename string) error { + info, err := os.Stat(filename) + if err != nil || !info.Mode().IsRegular() || info.Mode()&0100 == 0 { + return errors.New("Package entry point is missing or not executable.") + } + binary, err := elf.Open(filename) + if err != nil { + return errors.New("Native package entry point must be an ELF executable.") + } + defer binary.Close() + machines := map[string]elf.Machine{"amd64": elf.EM_X86_64, "arm64": elf.EM_AARCH64, "386": elf.EM_386, "arm": elf.EM_ARM, "riscv64": elf.EM_RISCV, "ppc64": elf.EM_PPC64, "ppc64le": elf.EM_PPC64, "s390x": elf.EM_S390} + machine, supported := machines[runtime.GOARCH] + if !supported || binary.Machine != machine || (binary.Type != elf.ET_EXEC && binary.Type != elf.ET_DYN) { + return errors.New("Package executable does not match this platform and architecture.") + } + return nil +} + +func sync_directory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return err + } + defer file.Close() + return file.Sync() +} + +// RemovePackage never deletes explicitly added local programs or paths outside +// the managed root. Call only after a saved removal and after the session joins. +func RemovePackage(root string, entry Entry) error { + directory := entry.PackageDirectory + if directory == "" { + return nil + } + rootInfo, rootError := os.Lstat(root) + if rootError != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return errors.New("Managed package root is unsafe.") + } + if filepath.Dir(directory) != filepath.Clean(root) || !strings.HasPrefix(filepath.Base(directory), "package-") || filepath.Clean(directory) != directory { + return errors.New("Refusing to remove a package outside the managed directory.") + } + info, err := os.Lstat(directory) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("Installed package directory is unsafe.") + } + if err := os.RemoveAll(directory); err != nil { + return err + } + return sync_directory(root) +} + +// RecoverPackages runs once, after a successful registry load, before accepting +// management requests. The registry is the commit point; orphaned directories +// from interrupted installs/removals contain no live selected code. +func RecoverPackages(root string, entries []Entry) error { + info, err := os.Lstat(root) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("Managed package root is unsafe.") + } + retained := make(map[string]bool) + for _, entry := range entries { + for _, filename := range append([]string{entry.PackageDirectory, entry.Directory}, entry.Program...) { + relative, err := filepath.Rel(root, filename) + if err == nil && package_path(filepath.ToSlash(relative)) { + retained[strings.Split(filepath.ToSlash(relative), "/")[0]] = true + } + } + } + files, err := os.ReadDir(root) + if err != nil { + return err + } + for _, file := range files { + name := file.Name() + owned := false + for _, prefix := range []string{".staging-", ".download-", "package-"} { + if suffix, found := strings.CutPrefix(name, prefix); found && suffix != "" && len(suffix) <= 10 && !strings.ContainsFunc(suffix, func(value rune) bool { return value < '0' || value > '9' }) { + owned = true + } + } + if !owned || retained[name] || file.Type()&os.ModeSymlink != 0 { + continue + } + if err := os.RemoveAll(filepath.Join(root, name)); err != nil { + return err + } + } + return sync_directory(root) +} diff --git a/internal/tentacle/install_test.go b/internal/tentacle/install_test.go new file mode 100644 index 0000000..6436593 --- /dev/null +++ b/internal/tentacle/install_test.go @@ -0,0 +1,395 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package tentacle + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +type packageTransport func(*http.Request) (*http.Response, error) + +func (transport packageTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +type archiveMember struct { + name string + data []byte + kind byte + mode int64 +} + +func package_archive(t *testing.T, members []archiveMember) []byte { + t.Helper() + var output bytes.Buffer + compressed := gzip.NewWriter(&output) + archive := tar.NewWriter(compressed) + for _, member := range members { + if member.kind == 0 { + member.kind = tar.TypeReg + } + header := &tar.Header{Name: member.name, Mode: member.mode, Size: int64(len(member.data)), Typeflag: member.kind} + if member.kind == tar.TypeSymlink || member.kind == tar.TypeLink { + header.Linkname = "../../outside" + } + if err := archive.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := archive.Write(member.data); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func package_manifest() string { + return "format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo']\napis=['commands','presentation']\ncapabilities=['presentation']\n[protocol]\nmajor=1\nminor=0\n" +} + +func package_fixture(t *testing.T, data []byte) (Repository, *http.Client) { + t.Helper() + digest := sha256.Sum256(data) + repository := Repository{URL: "https://example.org/team/tentacles", ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40), + Tentacles: []Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1", Capabilities: []string{"presentation"}, + Releases: []Release{{Platform: runtime.GOOS, Architecture: runtime.GOARCH, URL: "https://example.org/releases/echo.tar.gz", SHA256: fmt.Sprintf("%x", digest)}}}}} + client := &http.Client{Transport: packageTransport(func(request *http.Request) (*http.Response, error) { + if err := request.Context().Err(); err != nil { + return nil, err + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data)), Header: make(http.Header)}, nil + })} + return repository, client +} + +func native_members(t *testing.T) []archiveMember { + t.Helper() + filename, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + return []archiveMember{{name: "tentacle.toml", data: []byte(package_manifest()), mode: 0644}, {name: "bin/echo", data: data, mode: 0755}} +} + +func TestPackageInstallPinsIdentityPermissionsAndSurvivesRestart(t *testing.T) { + data := package_archive(t, native_members(t)) + repository, client := package_fixture(t, data) + root := filepath.Join(t.TempDir(), "packages") + prepared, err := prepare_package(context.Background(), root, repository, "echo", client) + if err != nil { + t.Fatal(err) + } + defer prepared.Discard() + entry := prepared.Entry + if entry.Enabled || !entry.Presentation || entry.ID != PackageID(repository, "echo") || entry.RepositoryRevision != repository.Revision || entry.PackageSHA256 != repository.Tentacles[0].Releases[0].SHA256 || filepath.Dir(entry.Program[0]) != filepath.Join(entry.PackageDirectory, "bin") { + t.Fatal(entry) + } + other := repository + other.URL = "https://other.example.org/team/tentacles" + if PackageID(other, "echo") == entry.ID { + t.Fatal("different origins share identity") + } + registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")} + if _, err := registry.Load(); err != nil { + t.Fatal(err) + } + if err := registry.SaveState([]Entry{entry}, []Repository{repository}); err != nil { + t.Fatal(err) + } + prepared.Retain() + reopened := &Registry{Path: registry.Path} + entries, err := reopened.Load() + if err != nil || len(entries) != 1 || entries[0].PackageSHA256 != entry.PackageSHA256 { + t.Fatalf("restart %v %v", entries, err) + } + if err := RemovePackage(root, entries[0]); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(entry.PackageDirectory); !os.IsNotExist(err) { + t.Fatal("package files retained", err) + } +} + +func TestPackageRejectsBadDownloadsAndManifestsWithoutFiles(t *testing.T) { + for _, problem := range []string{"digest", "status", "unsupported architecture", "version", "grant", "runtime", "protocol", "api", "entry traversal", "missing executable", "not executable", "not ELF", "cancelled", "corrupt gzip"} { + t.Run(problem, func(t *testing.T) { + members := native_members(t) + switch problem { + case "version": + members[0].data = []byte(strings.Replace(package_manifest(), "version='0.1'", "version='0.2'", 1)) + case "grant": + members[0].data = []byte(strings.Replace(package_manifest(), "capabilities=['presentation']", "capabilities=[]", 1)) + case "runtime": + members[0].data = []byte(strings.Replace(package_manifest(), "runtime='native'", "runtime='python'", 1)) + case "protocol": + members[0].data = []byte(strings.Replace(package_manifest(), "major=1", "major=2", 1)) + case "api": + members[0].data = []byte(strings.Replace(package_manifest(), "'commands','presentation'", "'commands','focused-text'", 1)) + case "entry traversal": + members[0].data = []byte(strings.Replace(package_manifest(), "bin/echo", "../echo", 1)) + case "missing executable": + members = members[:1] + case "not executable": + members[1].mode = 0644 + case "not ELF": + members[1].data = []byte("#!/bin/sh\nexit 0\n") + } + data := package_archive(t, members) + if problem == "corrupt gzip" { + data[len(data)-8] ^= 0xff + } + repository, client := package_fixture(t, data) + if problem == "digest" { + repository.Tentacles[0].Releases[0].SHA256 = strings.Repeat("0", 64) + } + if problem == "unsupported architecture" { + repository.Tentacles[0].Releases[0].Architecture = "unknown" + } + if problem == "status" { + client.Transport = packageTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("missing"))}, nil + }) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if problem == "cancelled" { + cancel() + } + root := filepath.Join(t.TempDir(), "packages") + if prepared, err := prepare_package(ctx, root, repository, "echo", client); err == nil { + prepared.Discard() + t.Fatal("accepted", problem) + } + files, err := os.ReadDir(root) + if !os.IsNotExist(err) && err != nil { + t.Fatal(err) + } + if len(files) != 0 { + t.Fatal("failed install left files", files) + } + }) + } +} + +func TestPackageExtractionRejectsEscapesLinksConflictsAndExpansion(t *testing.T) { + cases := [][]archiveMember{ + {{name: "../outside", data: []byte("bad")}}, {{name: "/absolute", data: []byte("bad")}}, + {{name: "bin/link", kind: tar.TypeSymlink}}, {{name: "bin/link", kind: tar.TypeLink}}, + {{name: "device", kind: tar.TypeFifo}}, {{name: "same"}, {name: "same"}}, + {{name: "bin", data: []byte("file")}, {name: "bin/child"}}, + {{name: "bin\\escape"}}, {{name: "./relative"}}, + {{name: "huge", data: bytes.Repeat([]byte{0}, maxExpandedBytes+1)}}, + } + for index, members := range cases { + t.Run(fmt.Sprint(index), func(t *testing.T) { + if err := extract_package(context.Background(), bytes.NewReader(package_archive(t, members)), t.TempDir()); err == nil { + t.Fatal("unsafe archive accepted") + } + }) + } +} + +func TestPackageRemovalPreservesLocalFilesAndRejectsForeignDirectories(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := RemovePackage(root, Entry{Program: []string{filepath.Join(outside, "local")}}); err != nil { + t.Fatal(err) + } + if err := RemovePackage(root, Entry{PackageDirectory: outside}); err == nil { + t.Fatal("outside path accepted") + } + link := filepath.Join(root, "package-link") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + if err := RemovePackage(root, Entry{PackageDirectory: link}); err == nil { + t.Fatal("symlink accepted") + } + if _, err := os.Stat(outside); err != nil { + t.Fatal("outside removed", err) + } +} + +func TestReleaseCatalogValidationAndDeepClone(t *testing.T) { + files := repository_files() + files["catalog.toml"] += "[[tentacles.releases]]\nplatform='linux'\narchitecture='amd64'\nurl='https://example.org/release.tar.gz'\nsha256='" + strings.Repeat("a", 64) + "'\n" + repository, err := read_repository_fixture(files) + if err != nil { + t.Fatal(err) + } + clone := CloneRepositories([]Repository{repository}) + clone[0].Tentacles[0].Releases[0].SHA256 = "changed" + clone[0].Tentacles[0].Capabilities[0] = "changed" + if repository.Tentacles[0].Releases[0].SHA256 == "changed" || repository.Tentacles[0].Capabilities[0] == "changed" { + t.Fatal("shared catalog storage") + } + for _, value := range []string{"http://example.org/archive", "https://Username@example.org/archive", "https://example.org/archive#fragment"} { + repository.Tentacles[0].Releases[0].URL = value + if ValidateRepositories([]Repository{repository}) == nil { + t.Fatal("unsafe URL accepted", value) + } + } +} + +func TestPackageRecoveryKeepsSelectedFilesAndLocalReferences(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"package-123", "package-456", ".staging-789", "package-user", "package-321"} { + if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, ".download-567"), []byte("partial"), 0600); err != nil { + t.Fatal(err) + } + entries := []Entry{{PackageDirectory: filepath.Join(root, "package-123")}, {Program: []string{filepath.Join(root, "package-321", "bin", "local")}}} + if err := RecoverPackages(root, entries); err != nil { + t.Fatal(err) + } + for _, name := range []string{"package-123", "package-user", "package-321"} { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Fatal("selected/local files lost", err) + } + } + for _, name := range []string{"package-456", ".staging-789", ".download-567"} { + if _, err := os.Stat(filepath.Join(root, name)); !os.IsNotExist(err) { + t.Fatal("orphan retained", name, err) + } + } +} + +func TestPackageRedirectsRejectHTTPAndCredentials(t *testing.T) { + for _, location := range []string{"http://example.org/archive", "https://Username@example.org/archive"} { + t.Run(location, func(t *testing.T) { + client := new_package_client() + calls := 0 + client.Transport = packageTransport(func(request *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 302, Header: http.Header{"Location": []string{location}}, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil + }) + if response, err := client.Get("https://example.org/release"); err == nil { + response.Body.Close() + t.Fatal("unsafe redirect accepted") + } + if calls != 1 { + t.Fatal("followed unsafe redirect", calls) + } + }) + } +} + +func TestBuiltHelloWorldPackageThroughHost(t *testing.T) { + root := os.Getenv("CTHULHU_TENTACLE_RELEASE_ROOT") + if root == "" { + t.Skip("set CTHULHU_TENTACLE_RELEASE_ROOT to validate prepared release artifacts") + } + repository, err := read_repository("https://example.org/team/tentacles", strings.Repeat("a", 40), func(filename string) ([]byte, error) { return os.ReadFile(filepath.Join(root, filename)) }) + if err != nil { + t.Fatal(err) + } + var manifest Manifest + for _, value := range repository.Tentacles { + if value.ID == "hello-world" { + manifest = value + } + } + index := -1 + for item, release := range manifest.Releases { + if release.Architecture == runtime.GOARCH && release.Platform == runtime.GOOS { + index = item + } + } + if index < 0 { + t.Fatal("no release for host") + } + data, err := os.ReadFile(filepath.Join(root, "dist", "hello-world-"+manifest.Version+"-linux-"+runtime.GOARCH+".tar.gz")) + if err != nil { + t.Fatal(err) + } + client := new_package_client() + client.Transport = packageTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil + }) + prepared, err := prepare_package(context.Background(), filepath.Join(t.TempDir(), "packages"), repository, "hello-world", client) + if err != nil { + t.Fatal(err) + } + defer prepared.Discard() + manager, err := NewManager(DefaultLimits(), nil) + if err != nil { + t.Fatal(err) + } + defer manager.Shutdown(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := manager.Start(ctx, prepared.Entry.Spec()) + if err != nil { + t.Fatal(err) + } + select { + case <-session.Ready(): + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + pending, err := session.Invoke(ctx, "hello-world", nil) + if err != nil { + t.Fatal(err) + } + select { + case result := <-pending: + if result.Err != nil { + t.Fatal(result.Err) + } + presentation, err := tentaclewire.DecodePresentation(result.Value) + if err != nil || presentation.Speech != "Hello world!" || presentation.Braille != "Hello world!" || !presentation.Uninterruptible { + t.Fatal(presentation, err) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + +func TestInstallInteractivePackageGrant(t *testing.T) { + members := native_members(t) + for index := range members { + if members[index].name == "tentacle.toml" { + members[index].data = []byte(strings.ReplaceAll(strings.ReplaceAll(package_manifest(), "apis=['commands','presentation']", "apis=['commands','presentation','interaction']"), "capabilities=['presentation']", "capabilities=['presentation','interaction']")) + } + } + data := package_archive(t, members) + repository, client := package_fixture(t, data) + repository.Tentacles[0].Capabilities = []string{"presentation", "interaction"} + prepared, err := prepare_package(context.Background(), t.TempDir(), repository, "echo", client) + if err != nil { + t.Fatal(err) + } + defer prepared.Discard() + if !prepared.Entry.Interaction || !prepared.Entry.Spec().Interaction || !prepared.Entry.Presentation { + t.Fatal(prepared.Entry) + } +} diff --git a/internal/tentacle/launch.go b/internal/tentacle/launch.go new file mode 100644 index 0000000..e5464aa --- /dev/null +++ b/internal/tentacle/launch.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +type launchReply struct { + PID int + Errno uint32 +} + +// launch passes exactly four descriptors: stdin, stdout, stderr and the already +// limited cgroup. The service performs clone3 inside its delegated tree. No +// shell, environment update, or settings operation is exposed by this protocol. +func (delegation *Delegation) launch(ctx context.Context, spec Spec, files ...*os.File) (int, <-chan struct{}, func(), error) { + connection, err := net.DialUnix("unixpacket", nil, &net.UnixAddr{Name: filepath.Join(delegation.directory, "launch"), Net: "unixpacket"}) + if err != nil { + return 0, nil, nil, err + } + keep := false + defer func() { + if !keep { + connection.Close() + } + }() + connection.SetDeadline(time.Now().Add(3 * time.Second)) + stopCancel := context.AfterFunc(ctx, func() { connection.Close() }) + defer func() { + if !keep { + stopCancel() + } + }() + data, err := json.Marshal(spec) + if err != nil || len(data) > MaxFrameBytes || len(files) != 4 { + return 0, nil, nil, ErrLimit + } + descriptors := make([]int, len(files)) + for index, file := range files { + descriptors[index] = int(file.Fd()) + } + if _, _, err := connection.WriteMsgUnix(data, syscall.UnixRights(descriptors...), nil); err != nil { + return 0, nil, nil, err + } + buffer := make([]byte, 256) + count, err := connection.Read(buffer) + if err != nil { + return 0, nil, nil, err + } + var reply launchReply + if err := json.Unmarshal(buffer[:count], &reply); err != nil { + return 0, nil, nil, ErrProtocol + } + if reply.Errno != 0 { + return 0, nil, nil, syscall.Errno(reply.Errno) + } + if reply.PID <= 0 { + return 0, nil, nil, ErrProtocol + } + connection.SetDeadline(time.Time{}) + done := make(chan struct{}) + go func() { defer stopCancel(); var end [1]byte; connection.Read(end[:]); connection.Close(); close(done) }() + keep = true + return reply.PID, done, func() { connection.Close() }, nil +} + +func serve_launches(input io.Reader, output io.Writer, socketPath, parent string) error { + listener, err := net.ListenUnix("unixpacket", &net.UnixAddr{Name: socketPath, Net: "unixpacket"}) + if err != nil { + return err + } + defer listener.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var workers sync.WaitGroup + capacity := make(chan struct{}, DefaultLimits().Processes) + accepted := make(chan struct{}) + go func() { + defer close(accepted) + for { + connection, err := listener.AcceptUnix() + if err != nil { + return + } + select { + case capacity <- struct{}{}: + workers.Add(1) + go func() { defer workers.Done(); defer func() { <-capacity }(); serve_launch(ctx, connection, parent) }() + default: + connection.Close() + } + } + }() + defer func() { cancel(); listener.Close(); <-accepted; workers.Wait() }() + if _, err := fmt.Fprintln(output, parent); err != nil { + return err + } + _, err = io.Copy(io.Discard, input) + return err +} + +func serve_launch(ctx context.Context, connection *net.UnixConn, parent string) { + defer connection.Close() + stop := context.AfterFunc(ctx, func() { connection.Close() }) + defer stop() + connection.SetDeadline(time.Now().Add(3 * time.Second)) + data, ancillary := make([]byte, MaxFrameBytes), make([]byte, syscall.CmsgSpace(4*4)) + count, rightsCount, flags, _, err := connection.ReadMsgUnix(data, ancillary) + if err != nil { + return + } + // Always close received descriptors, even when framing or validation fails. + var files []*os.File + defer func() { + for _, file := range files { + file.Close() + } + }() + messages, err := syscall.ParseSocketControlMessage(ancillary[:rightsCount]) + if err != nil { + return + } + for _, message := range messages { + descriptors, err := syscall.ParseUnixRights(&message) + if err != nil { + return + } + for _, descriptor := range descriptors { + syscall.CloseOnExec(descriptor) + files = append(files, os.NewFile(uintptr(descriptor), "tentacle-launch")) + } + } + if flags&(syscall.MSG_TRUNC|syscall.MSG_CTRUNC) != 0 || len(files) != 4 { + return + } + var spec Spec + if decode_strict(data[:count], &spec) != nil || validate_spec(spec) != nil { + return + } + var stat syscall.Statfs_t + if syscall.Fstatfs(int(files[3].Fd()), &stat) != nil || stat.Type != 0x63677270 { + return + } + groupPath, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", files[3].Fd())) + if err != nil { + return + } + relative, err := filepath.Rel(parent, groupPath) + parts := strings.Split(relative, string(filepath.Separator)) + if err != nil || len(parts) != 2 || !strings.HasPrefix(parts[0], "cthulhu-tentacles-") { + return + } + for _, identity := range []string{strings.TrimPrefix(parts[0], "cthulhu-tentacles-"), parts[1]} { + if decoded, err := hex.DecodeString(identity); err != nil || len(decoded) != 16 { + return + } + } + command := exec.Command(spec.Program[0], spec.Program[1:]...) + command.Dir = spec.Directory + command.Stdin, command.Stdout, command.Stderr = files[0], files[1], files[2] + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, UseCgroupFD: true, CgroupFD: int(files[3].Fd())} + if err := command.Start(); err != nil { + var errno syscall.Errno + if !errors.As(err, &errno) { + errno = syscall.EIO + } + reply, _ := json.Marshal(launchReply{Errno: uint32(errno)}) + connection.Write(reply) + return + } + for _, file := range files { + file.Close() + } + files = nil + // A closed launch connection kills the direct child. The reader's cgroup + // cleanup or systemd's guardian-exit cleanup covers every descendant. + disconnected := make(chan struct{}) + go func() { defer close(disconnected); var end [1]byte; connection.Read(end[:]); command.Process.Kill() }() + reply, _ := json.Marshal(launchReply{PID: command.Process.Pid}) + if _, err := connection.Write(reply); err != nil { + command.Process.Kill() + } + connection.SetDeadline(time.Time{}) + _ = command.Wait() + connection.Write([]byte{0}) + connection.Close() + <-disconnected +} diff --git a/internal/tentacle/load_test.go b/internal/tentacle/load_test.go new file mode 100644 index 0000000..b24be7e --- /dev/null +++ b/internal/tentacle/load_test.go @@ -0,0 +1,114 @@ +package tentacle + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "testing" +) + +func TestPythonPeerUsesSameContract(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("Python 3 not installed") + } + fixture, err := filepath.Abs("testdata/peer.py") + if err != nil { + t.Fatal(err) + } + manager := test_manager(t, DefaultLimits(), nil) + session, err := manager.Start(context.Background(), Spec{ID: "python", Program: []string{python, "-I", "-u", fixture}}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if err := session.Activate(); err != nil { + t.Fatal(err) + } + arguments := json.RawMessage(`{"text":"é猫😀é","enabled":false,"count":0}`) + pending, err := session.Invoke(context.Background(), "echo", arguments) + if err != nil { + t.Fatal(err) + } + result := wait_result(t, pending) + var value struct { + Text string + Enabled bool + Count int + } + if result.Err != nil || json.Unmarshal(result.Value, &value) != nil || value.Text != "é猫😀é" || value.Enabled || value.Count != 0 { + t.Fatalf("Python round trip: %+v, error %v", value, result.Err) + } + if result.Session != session.Identity() { + t.Fatal("wrong session") + } +} + +func TestManyTentaclesHaveAggregateBoundAndIndependentProgress(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + sessions := make([]*Session, 0, 16) + // Keep one healthy peer alongside fifteen peers that never reply. + healthy := start_active(t, manager, "healthy", "echo") + for index := range 15 { + sessions = append(sessions, start_active(t, manager, fmt.Sprintf("blocked-%d", index), "timeout")) + } + for index := range 63 { + if _, err := sessions[index%len(sessions)].Invoke(context.Background(), "echo", nil); err != nil { + t.Fatal(err) + } + } + result, err := healthy.Invoke(context.Background(), "echo", json.RawMessage(`{"healthy":true}`)) + if err != nil { + t.Fatal(err) + } + if reply := wait_result(t, result); reply.Err != nil || string(reply.Value) != `{"healthy":true}` { + t.Fatalf("healthy peer stalled: %+v", reply) + } + if _, err := sessions[0].Invoke(context.Background(), "echo", nil); err != nil { + t.Fatal(err) + } + if _, err := healthy.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrBusy) { + t.Fatal("missing aggregate backpressure", err) + } + if stats := manager.Stats(); stats.Pending != 64 || stats.Processes != 16 { + t.Fatalf("%+v", stats) + } + for _, session := range sessions { + session.Disable() + } + result, err = healthy.Invoke(context.Background(), "echo", nil) + if err != nil { + t.Fatal(err) + } + if reply := wait_result(t, result); reply.Err != nil { + t.Fatal(reply.Err) + } +} + +func BenchmarkCommandRoundTrip(b *testing.B) { + for _, count := range []int{1, 8, 16} { + b.Run(fmt.Sprintf("processes-%d", count), func(b *testing.B) { + manager := test_manager(b, DefaultLimits(), nil) + sessions := make([]*Session, count) + for index := range count { + sessions[index] = start_active(b, manager, fmt.Sprintf("peer-%d", index), "echo") + } + arguments := json.RawMessage(`{"text":"sample"}`) + b.ReportAllocs() + b.ResetTimer() + for index := range b.N { + pending, err := sessions[index%count].Invoke(context.Background(), "echo", arguments) + if err != nil { + b.Fatal(err) + } + if result := <-pending; result.Err != nil { + b.Fatal(result.Err) + } + } + b.StopTimer() + }) + } +} diff --git a/internal/tentacle/local.go b/internal/tentacle/local.go new file mode 100644 index 0000000..f1651ca --- /dev/null +++ b/internal/tentacle/local.go @@ -0,0 +1,92 @@ +package tentacle + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +// LoadLocalSpecs reads an explicit development launch list, not a manifest or +// installation catalog. It does not execute code or discover directories. +func LoadLocalSpecs(path string) ([]Spec, error) { + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("local tentacle list must be a regular file") + } + data, err := io.ReadAll(io.LimitReader(file, MaxFrameBytes+1)) + if err != nil { + return nil, err + } + if len(data) > MaxFrameBytes { + return nil, ErrLimit + } + if err := validate_json(data); err != nil { + return nil, err + } + var specs []Spec + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&specs); err != nil { + return nil, err + } + if len(specs) > DefaultLimits().Processes { + return nil, ErrLimit + } + seen := make(map[string]bool) + for _, spec := range specs { + if err := validate_spec(spec); err != nil { + return nil, err + } + if seen[spec.ID] { + return nil, fmt.Errorf("duplicate local tentacle ID") + } + seen[spec.ID] = true + } + return specs, nil +} + +// DelegatedParent enables controllers only within the launcher's explicitly +// delegated unit. The reader already lives in its own leaf; it is never moved +// here. Other launchers can supply an already configured absolute parent. +func DelegatedParent() (string, error) { + data, err := os.ReadFile("/proc/self/cgroup") + if err != nil { + return "", err + } + parent, err := delegated_parent(string(data)) + if err != nil { + return "", err + } + if err := write_control(parent, "cgroup.subtree_control", "+cpu +memory +pids"); err != nil { + return "", err + } + return parent, nil +} + +func delegated_parent(membership string) (string, error) { + for _, line := range strings.Split(membership, "\n") { + if !strings.HasPrefix(line, "0::/") { + continue + } + path := strings.TrimPrefix(line, "0::") + if filepath.Clean(path) != path || filepath.Base(path) != "reader" || + !strings.HasSuffix(filepath.Dir(path), ".service") { + break + } + return filepath.Join("/sys/fs/cgroup", filepath.Dir(path)), nil + } + return "", fmt.Errorf("%w: reader requires a delegated service with DelegateSubgroup=reader", ErrContainment) +} diff --git a/internal/tentacle/local_test.go b/internal/tentacle/local_test.go new file mode 100644 index 0000000..07f019a --- /dev/null +++ b/internal/tentacle/local_test.go @@ -0,0 +1,43 @@ +package tentacle + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDelegatedReaderParent(t *testing.T) { + parent, err := delegated_parent("0::/user.slice/test.service/reader\n") + if err != nil || parent != "/sys/fs/cgroup/user.slice/test.service" { + t.Fatalf("%s %v", parent, err) + } + for _, path := range []string{"0::/", "0::/test.service", "0::/session.scope/reader", "0::/test.service/../reader", "1:cpu:/test.service/reader"} { + if _, err := delegated_parent(path); err == nil { + t.Fatalf("accepted %q", path) + } + } +} + +func TestLocalLaunchListValidation(t *testing.T) { + path := filepath.Join(t.TempDir(), "tentacles.json") + for _, data := range []string{ + `[{"ID":"echo","Program":["/bin/true"]}]`, + `[{"ID":"echo","Program":["relative"]}]`, + `[{"ID":"echo","Program":["/bin/true"]},{"ID":"echo","Program":["/bin/true"]}]`, + strings.Repeat(" ", MaxFrameBytes+1), + `[{"ID":"echo","ID":"other","Program":["/bin/true"]}]`, + } { + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + specs, err := LoadLocalSpecs(path) + valid := data == `[{"ID":"echo","Program":["/bin/true"]}]` + if (err == nil) != valid { + t.Fatalf("validation: %v", err) + } + if valid && len(specs) != 1 { + t.Fatal(specs) + } + } +} diff --git a/internal/tentacle/management.go b/internal/tentacle/management.go new file mode 100644 index 0000000..a7dfd77 --- /dev/null +++ b/internal/tentacle/management.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +// ManagementRequest is the internal Preferences contract. Mutations persist +// immediately, independently of the reader's speech/settings draft. +type ManagementRequest struct { + Action string // list, settings, add/update/remove/enable/disable/retry, repository-add/refresh/remove + ID string + Entry *Entry // Required only for add/update; ID must match. + URL string // Repository origin for repository-add/refresh/remove. +} + +type EntryStatus struct { + Entry Entry + State string // disabled, starting, active, stopping, failed, or suppressed + Message string // Core-owned text, never child diagnostics or returned data. + Shortcuts []string // Acknowledged native assignments, labelled by layout. +} + +type Snapshot struct { + Entries []EntryStatus + Message string + Suppressed bool + Repositories []Repository +} diff --git a/internal/tentacle/manager.go b/internal/tentacle/manager.go new file mode 100644 index 0000000..77ee3b8 --- /dev/null +++ b/internal/tentacle/manager.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "path/filepath" + "strings" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type Limits struct { + Processes int + Starting int + PendingPerProcess int + PendingTotal int + StartupTimeout time.Duration + RequestTimeout time.Duration + ShutdownTimeout time.Duration +} + +func DefaultLimits() Limits { + return Limits{Processes: 16, Starting: 2, PendingPerProcess: 8, PendingTotal: 64, + StartupTimeout: 3 * time.Second, RequestTimeout: 2 * time.Second, ShutdownTimeout: 250 * time.Millisecond} +} + +// Spec is a validated launch description, independent of package metadata. +// Program[0] must be absolute; arguments are never interpreted by a shell. +type Spec struct { + ID string + Program []string + Directory string + Presentation bool // Explicit local grant for command speech/braille results. + Interaction bool // Internal forms and explicit focused-item/clipboard exchanges. +} + +type Stats struct { + Processes int + Starting int + Pending int +} + +type Manager struct { + mutex sync.Mutex + limits Limits + sessions map[string]*Session + starting chan struct{} + pending chan struct{} + closed bool + coreBindings map[input.KeyboardLayout]input.CommandBindings + containment *containment + delegation *Delegation + cleanupError error +} + +// NewDelegatedManager keeps exec inside the delegated service. This is required +// when the reader's own cgroup is outside that service's delegation boundary. +func NewDelegatedManager(delegation *Delegation) (*Manager, error) { + manager, err := NewContainedManager(DefaultLimits(), nil, delegation.Parent, DefaultResourceLimits()) + if err == nil { + manager.delegation = delegation + } + return manager, err +} + +// NewManager is the uncontained host for explicit trusted protocol fixtures. +// Reader integration must use NewContainedManager; there is no implicit fallback. +func NewManager(limits Limits, overrides []input.BindingOverride) (*Manager, error) { + if limits.Processes < 1 || limits.Starting < 1 || limits.Starting > limits.Processes || + limits.PendingPerProcess < 1 || limits.PendingTotal < limits.PendingPerProcess || + limits.StartupTimeout <= 0 || limits.RequestTimeout <= 0 || limits.ShutdownTimeout <= 0 { + return nil, errors.New("invalid tentacle host limits") + } + if err := input.ValidateBindingOverrides(overrides); err != nil { + return nil, err + } + coreBindings := make(map[input.KeyboardLayout]input.CommandBindings) + for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} { + bindings, err := input.ResolveCommandBindings(layout, true, overrides) + if err != nil { + return nil, err + } + coreBindings[layout] = bindings + } + return &Manager{limits: limits, sessions: make(map[string]*Session), coreBindings: coreBindings, + starting: make(chan struct{}, limits.Starting), pending: make(chan struct{}, limits.PendingTotal)}, nil +} + +// Start reserves capacity and returns immediately. Ready closes after the child +// has either staged a valid registration or failed; Done includes worker joins. +// Rejected admission starts no process and changes no desired enablement. +func (manager *Manager) Start(ctx context.Context, spec Spec) (*Session, error) { + if ctx == nil { + return nil, errors.New("nil tentacle context") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := validate_spec(spec); err != nil { + return nil, err + } + manager.mutex.Lock() + defer manager.mutex.Unlock() + if manager.cleanupError != nil { + return nil, manager.cleanupError + } + if manager.closed { + return nil, ErrUnavailable + } + if len(manager.sessions) >= manager.limits.Processes || manager.sessions[spec.ID] != nil { + return nil, ErrBusy + } + select { + case manager.starting <- struct{}{}: + default: + return nil, ErrBusy + } + var identity [16]byte + if _, err := rand.Read(identity[:]); err != nil { + <-manager.starting + return nil, err + } + spec.Program = append([]string{}, spec.Program...) + sessionContext, cancel := context.WithCancel(ctx) + outputContext, revokeOutput := context.WithCancel(sessionContext) + session := &Session{manager: manager, spec: spec, identity: hex.EncodeToString(identity[:]), + outputContext: outputContext, revokeOutput: revokeOutput, + ctx: sessionContext, cancel: cancel, state: Starting, ready: make(chan struct{}), done: make(chan struct{}), + stop: make(chan struct{}), revoked: make(chan struct{}), outbound: make(chan outgoing, manager.limits.PendingPerProcess+2), + pending: make(map[string]*pendingCall)} + manager.sessions[spec.ID] = session + go session.run() + return session, nil +} + +func (manager *Manager) Stats() Stats { + manager.mutex.Lock() + defer manager.mutex.Unlock() + return Stats{Processes: len(manager.sessions), Starting: len(manager.starting), Pending: len(manager.pending)} +} + +// Shutdown may wait for children and is intended for the lifecycle worker. +// Disable itself is nonblocking and revokes dispatch before requesting exit. +func (manager *Manager) Shutdown(ctx context.Context) error { + manager.mutex.Lock() + manager.closed = true + sessions := make([]*Session, 0, len(manager.sessions)) + for _, session := range manager.sessions { + sessions = append(sessions, session) + session.Disable() + } + manager.mutex.Unlock() + for _, session := range sessions { + select { + case <-session.Done(): + case <-ctx.Done(): + return ctx.Err() + } + } + manager.mutex.Lock() + defer manager.mutex.Unlock() + if manager.cleanupError != nil { + return manager.cleanupError + } + if manager.containment != nil { + if err := manager.containment.group.remove(); err != nil { + manager.cleanupError = err + return err + } + manager.containment = nil + } + return nil +} + +func validate_spec(spec Spec) error { + if spec.Interaction && !spec.Presentation || !valid_id(spec.ID) || len(spec.Program) == 0 || len(spec.Program) > 32 || + !filepath.IsAbs(spec.Program[0]) || (spec.Directory != "" && !filepath.IsAbs(spec.Directory)) { + return errors.New("invalid local tentacle launch") + } + for _, argument := range append(append([]string{}, spec.Program...), spec.Directory) { + if len(argument) > 4096 || strings.ContainsRune(argument, 0) { + return errors.New("invalid local tentacle argument") + } + } + return nil +} diff --git a/internal/tentacle/process.go b/internal/tentacle/process.go new file mode 100644 index 0000000..2f264b0 --- /dev/null +++ b/internal/tentacle/process.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + "time" +) + +func (session *Session) run() { + var cleanupError error + starting := true + ready := func() { + if starting { + starting = false + <-session.manager.starting + close(session.ready) + } + } + defer func() { + session.cancel() + session.mutex.Lock() + if cleanupError != nil { + session.fail_locked(cleanupError) + // Even a requested disable must report incomplete containment. + session.state, session.failure = Failed, cleanupError + } else if session.state == Stopping { + session.state = Stopped + } else if session.state != Failed { + session.fail_locked(ErrExited) + } + session.mutex.Unlock() + session.callbacks.Wait() + // A retained status/session handle must not retain unsent argument text. + for len(session.outbound) != 0 { + <-session.outbound + } + ready() + session.manager.mutex.Lock() + if cleanupError != nil { + // Keep the admission slot and reject further starts. Do not claim + // capacity was reclaimed when owned processes may remain alive. + session.manager.cleanupError = cleanupError + } else { + delete(session.manager.sessions, session.spec.ID) + } + close(session.done) + session.manager.mutex.Unlock() + }() + select { + case <-session.stop: + return + case <-session.ctx.Done(): + session.fail(session.ctx.Err()) + return + default: + } + + // Use owned files instead of exec's copier goroutines. Closing our ends + // unblocks workers even when a descendant retains an inherited pipe. + var pipes []*os.File + defer func() { + for _, pipe := range pipes { + pipe.Close() + } + }() + newPipe := func() (*os.File, *os.File, error) { + reader, writer, err := os.Pipe() + if err == nil { + pipes = append(pipes, reader, writer) + } + return reader, writer, err + } + childInput, input, err := newPipe() + if err != nil { + session.fail(err) + return + } + output, childOutput, err := newPipe() + if err != nil { + session.fail(err) + return + } + diagnostics, childDiagnostics, err := newPipe() + if err != nil { + session.fail(err) + return + } + command := exec.Command(session.spec.Program[0], session.spec.Program[1:]...) + command.Dir = session.spec.Directory + command.Stdin, command.Stdout, command.Stderr = childInput, childOutput, childDiagnostics + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var group *cgroup + if containment := session.manager.containment; containment != nil { + group, err = create_cgroup(filepath.Join(containment.group.path, session.identity), containment.limits.PerSession, 0) + if err != nil { + session.fail(err) + return + } + defer func() { cleanupError = group.kill_and_remove(containment.limits.CleanupTimeout) }() + // clone3(CLONE_INTO_CGROUP) applies limits before the child executes. + // Moving an already-running PID would leave a fork/allocation race. + command.SysProcAttr.UseCgroupFD = true + command.SysProcAttr.CgroupFD = int(group.file.Fd()) + } + var pid int + var exited <-chan struct{} + if session.manager.delegation != nil { + var closeLaunch func() + pid, exited, closeLaunch, err = session.manager.delegation.launch(session.ctx, session.spec, childInput, childOutput, childDiagnostics, group.file) + if err == nil { + defer closeLaunch() + } + } else { + err = command.Start() + if err == nil { + pid = command.Process.Pid + done := make(chan struct{}) + exited = done + go func() { _ = command.Wait(); close(done) }() + } + } + if err != nil { + // Do not retain the command line, environment or child-supplied text. + if group != nil { + var errno syscall.Errno + if errors.As(err, &errno) { + session.fail(fmt.Errorf("%w: child placement/exec: %w", ErrContainment, errno)) + } else { + session.fail(ErrContainment) + } + } else { + session.fail(ErrExited) + } + return + } + childInput.Close() + childOutput.Close() + childDiagnostics.Close() + var workers sync.WaitGroup + workers.Add(3) + go func() { defer workers.Done(); session.write_requests(input) }() + go func() { defer workers.Done(); session.read_replies(output) }() + go func() { defer workers.Done(); session.drain_diagnostics(diagnostics) }() + defer func() { + session.cancel() + if group != nil { + _ = write_control(group.path, "cgroup.kill", "1") + if command.Process != nil { + _ = command.Process.Kill() + } + } else { + // Explicit trusted-fixture mode only; detached descendants escape. + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + input.Close() + output.Close() + diagnostics.Close() + <-exited + workers.Wait() + }() + + session.mutex.Lock() + var initialized <-chan Result + if session.state == Starting && session.ctx.Err() == nil { + apis := []string{"commands"} + if session.spec.Presentation { + apis = append(apis, "presentation") + } + if session.spec.Interaction { + apis = append(apis, "interaction") + } + initialized, err = session.request_locked(session.ctx, "initialize", Initialization{ + Protocol: Version{Major: 1, Minor: 0}, Session: session.identity, + APIs: apis, Capabilities: apis, + }, session.manager.limits.StartupTimeout) + } else { + err = ErrUnavailable + } + session.mutex.Unlock() + if err != nil { + session.fail(err) + return + } + select { + case result := <-initialized: + if result.Err != nil { + session.fail(result.Err) + return + } + var registration Registration + if decode_strict(result.Value, ®istration) != nil || validate_registration(registration, session.identity) != nil { + session.fail(ErrProtocol) + return + } + for _, command := range registration.Commands { + if command.Presentation && !session.spec.Presentation || command.Interaction && (!command.Presentation || !session.spec.Interaction) { + session.fail(ErrProtocol) + return + } + } + session.mutex.Lock() + if session.state == Starting && session.ctx.Err() == nil { + session.commands = registration.Commands + session.state = Ready + } + session.mutex.Unlock() + ready() + case <-session.ctx.Done(): + session.fail(session.ctx.Err()) + return + case <-exited: + session.fail(ErrExited) + return + case <-session.stop: + session.wait_shutdown(exited) + return + } + select { + case <-session.ctx.Done(): + session.fail(session.ctx.Err()) + case <-exited: + session.fail(ErrExited) + case <-session.stop: + session.wait_shutdown(exited) + } +} + +func (session *Session) wait_shutdown(exited <-chan struct{}) { + timer := time.NewTimer(session.manager.limits.ShutdownTimeout) + defer timer.Stop() + select { + case <-exited: + case <-timer.C: + case <-session.ctx.Done(): + } +} + +func (session *Session) write_requests(writer io.Writer) { + for { + select { + case <-session.ctx.Done(): + return + case item := <-session.outbound: + if item.id != "" { + session.mutex.Lock() + call := session.pending[item.id] + eligible := call != nil && call.ctx.Err() == nil + session.mutex.Unlock() + if !eligible { + continue + } + } + if _, err := writer.Write(item.frame); err != nil { + session.fail(ErrExited) + return + } + } + } +} + +func (session *Session) read_replies(reader io.Reader) { + buffer := bufio.NewReaderSize(reader, 4096) + for session.ctx.Err() == nil { + frame, err := read_frame(buffer) + if err != nil { + if err == io.EOF { + err = ErrExited + } + session.fail(err) + return + } + reply, err := decode_response(frame) + if err != nil { + session.fail(err) + return + } + session.accept_reply(reply) + } +} + +func (session *Session) drain_diagnostics(reader io.Reader) { + count, err := io.Copy(io.Discard, io.LimitReader(reader, MaxDiagnosticBytes+1)) + if count > MaxDiagnosticBytes { + session.fail(ErrLimit) + } else if err != nil && session.ctx.Err() == nil { + session.fail(ErrExited) + } +} diff --git a/internal/tentacle/protocol.go b/internal/tentacle/protocol.go new file mode 100644 index 0000000..ea41929 --- /dev/null +++ b/internal/tentacle/protocol.go @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package tentacle implements the experimental process host used by explicit +// local reader launches. Start and Invoke never wait for child pipe I/O. +package tentacle + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + "strings" +) + +const ( + MaxFrameBytes = tentaclewire.MaxFrameBytes // Excludes the mandatory newline. + MaxArgumentBytes = 16 * 1024 + MaxCommands = 32 + MaxDiagnosticBytes = 64 * 1024 // Lifetime budget; diagnostics are discarded. +) + +var ( + ErrBusy = errors.New("tentacle host capacity exhausted") + ErrUnavailable = errors.New("tentacle is not active") + ErrDisabled = errors.New("tentacle disabled") + ErrProtocol = tentaclewire.ErrProtocol + ErrLimit = tentaclewire.ErrLimit + ErrExited = errors.New("tentacle process exited") +) + +type Version struct { + Major int `json:"major"` + Minor int `json:"minor"` +} + +type Command struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Bindings []Binding `json:"bindings,omitempty"` + Presentation bool `json:"presentation,omitempty"` + Interaction bool `json:"interaction,omitempty"` + Settings bool `json:"settings,omitempty"` // Opens settings; never a continuation handler. +} + +type Initialization struct { + Protocol Version `json:"protocol"` + Session string `json:"session"` + APIs []string `json:"apis"` + Capabilities []string `json:"capabilities"` +} + +type Registration struct { + Protocol Version `json:"protocol"` + Session string `json:"session"` + Commands []Command `json:"commands"` +} + +// RPCError retains the bounded remote message for a future accessible details +// view. Error deliberately omits child-supplied text from normal diagnostics. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (failure *RPCError) Error() string { + return fmt.Sprintf("tentacle RPC error %d", failure.Code) +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Result json.RawMessage `json:"result"` + Error *RPCError `json:"error"` +} + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id,omitempty"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +func encode_request(id, method string, params any) ([]byte, error) { + data, err := json.Marshal(request{JSONRPC: "2.0", ID: id, Method: method, Params: params}) + if err != nil { + return nil, ErrProtocol + } + if len(data) > MaxFrameBytes { + return nil, ErrLimit + } + return append(data, '\n'), nil +} + +func read_frame(reader *bufio.Reader) ([]byte, error) { return tentaclewire.ReadFrame(reader) } +func validate_json(data []byte) error { return tentaclewire.ValidateJSON(data) } +func decode_strict(data []byte, target any) error { return tentaclewire.DecodeStrict(data, target) } + +func decode_response(data []byte) (response, error) { + var reply response + if err := decode_strict(data, &reply); err != nil { + return reply, err + } + // Presence matters: a null result is valid; a null error is not. + var fields map[string]json.RawMessage + _ = json.Unmarshal(data, &fields) + for key := range fields { + if key != "jsonrpc" && key != "id" && key != "result" && key != "error" { + return reply, ErrProtocol + } + } + _, hasResult := fields["result"] + _, hasError := fields["error"] + if reply.JSONRPC != "2.0" || reply.ID == "" || len(reply.ID) > 32 || hasResult == hasError || + (hasError && (reply.Error == nil || len(reply.Error.Message) > 1024)) { + return reply, ErrProtocol + } + if hasError { + var errorFields map[string]json.RawMessage + _ = json.Unmarshal(fields["error"], &errorFields) + if errorFields["code"] == nil || bytes.Equal(errorFields["code"], []byte("null")) || + errorFields["message"] == nil || bytes.Equal(errorFields["message"], []byte("null")) { + return reply, ErrProtocol + } + } + return reply, nil +} + +func valid_id(value string) bool { + if len(value) == 0 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' { + return false + } + for _, char := range value { + if !(char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' || char == '.') { + return false + } + } + return true +} + +func validate_registration(registration Registration, session string) error { + if registration.Protocol != (Version{Major: 1, Minor: 0}) || registration.Session != session || + len(registration.Commands) == 0 || len(registration.Commands) > MaxCommands { + return ErrProtocol + } + seen := make(map[string]bool) + settings := false + for _, command := range registration.Commands { + if command.Settings && (!command.Interaction || settings) || command.Interaction && !command.Presentation || !valid_id(command.ID) || seen[command.ID] || strings.TrimSpace(command.Title) == "" || + len(command.Title) > 256 || len(command.Description) > 1024 { + return ErrProtocol + } + seen[command.ID] = true + settings = settings || command.Settings + if len(command.Bindings) > 4 { + return ErrLimit + } + for _, binding := range command.Bindings { + if err := validate_binding(binding); err != nil { + return err + } + } + } + return nil +} + +func clone_commands(commands []Command) []Command { + result := append([]Command(nil), commands...) + for index := range result { + result[index].Bindings = append([]Binding(nil), result[index].Bindings...) + } + return result +} diff --git a/internal/tentacle/protocol_test.go b/internal/tentacle/protocol_test.go new file mode 100644 index 0000000..aacae74 --- /dev/null +++ b/internal/tentacle/protocol_test.go @@ -0,0 +1,95 @@ +package tentacle + +import ( + "bufio" + "bytes" + "errors" + "io" + "strings" + "testing" +) + +func TestFramingBoundsAndRequiresNewline(t *testing.T) { + for _, testCase := range []struct { + name string + input string + want error + }{ + {"valid", strings.Repeat("x", MaxFrameBytes) + "\n", nil}, + {"oversized", strings.Repeat("x", MaxFrameBytes+1) + "\n", ErrLimit}, + {"unterminated", `{}`, io.ErrUnexpectedEOF}, + {"empty", "", io.EOF}, + } { + t.Run(testCase.name, func(t *testing.T) { + _, err := read_frame(bufio.NewReaderSize(strings.NewReader(testCase.input), 32)) + if !errors.Is(err, testCase.want) { + t.Fatalf("got %v, want %v", err, testCase.want) + } + }) + } +} + +func TestResponseValidation(t *testing.T) { + for _, valid := range []string{ + `{"jsonrpc":"2.0","id":"1","result":null}`, + `{"jsonrpc":"2.0","id":"1","result":{"text":"é猫😀é"}}`, + `{"jsonrpc":"2.0","id":"1","error":{"code":-32601,"message":"Unknown method"}}`, + } { + if _, err := decode_response([]byte(valid)); err != nil { + t.Errorf("valid response rejected: %v", err) + } + } + for _, invalid := range []string{ + `[]`, `null`, `{}`, `{"jsonrpc":"2.0","id":"1"}`, + `{"jsonrpc":"2.0","id":"1","result":null,"error":null}`, + `{"jsonrpc":"2.0","id":"1","error":null}`, + `{"jsonrpc":"2.0","id":1,"result":null}`, + `{"jsonrpc":"1.0","id":"1","result":null}`, + `{"jsonrpc":"2.0","id":"1","id":"2","result":null}`, + `{"jsonrpc":"2.0","id":"1","result":{"x":0,"x":1}}`, + `{"jsonrpc":"2.0","id":"1","result":null,"extra":1}`, + `{"jsonrpc":"2.0","id":"1","error":{"message":"missing code"}}`, + `{"jsonrpc":"2.0","id":"1","error":{"code":null,"message":"null code"}}`, + `{"jsonrpc":"2.0","id":"1","error":{"code":1,"message":null}}`, + `{"jsonrpc":"2.0","id":"1","result":"` + string([]byte{0xff}) + `"}`, + `{"jsonrpc":"2.0","id":"1","result":` + strings.Repeat("[", 40) + "0" + strings.Repeat("]", 40) + `}`, + `{"jsonrpc":"2.0","id":"1","result":null} {}`, + } { + if _, err := decode_response([]byte(invalid)); err == nil { + t.Errorf("invalid response accepted: %q", invalid) + } + } + if strings.Contains((&RPCError{Code: 1, Message: "private text"}).Error(), "private") { + t.Fatal("remote text leaked through Error") + } +} + +func FuzzResponseDecoder(f *testing.F) { + f.Add([]byte(`{"jsonrpc":"2.0","id":"1","result":null}`)) + f.Add([]byte(`{"jsonrpc":"2.0","id":"1","error":{"code":1,"message":"bad"}}`)) + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > MaxFrameBytes { + t.Skip() + } + _, _ = decode_response(data) + _, _ = read_frame(bufio.NewReader(bytes.NewReader(data))) + }) +} + +func TestSettingsRegistrationRequiresOneInteractiveCommand(t *testing.T) { + command := Command{ID: "settings", Title: "Settings", Settings: true, Interaction: true, Presentation: true} + registration := Registration{Protocol: Version{Major: 1}, Session: "test", Commands: []Command{command}} + if err := validate_registration(registration, "test"); err != nil { + t.Fatal(err) + } + registration.Commands[0].Interaction = false + if err := validate_registration(registration, "test"); err == nil { + t.Fatal("accepted a settings handler without interaction") + } + registration.Commands[0] = command + command.ID = "other-settings" + registration.Commands = append(registration.Commands, command) + if err := validate_registration(registration, "test"); err == nil { + t.Fatal("accepted two settings entry points") + } +} diff --git a/internal/tentacle/registry.go b/internal/tentacle/registry.go new file mode 100644 index 0000000..eecd0a5 --- /dev/null +++ b/internal/tentacle/registry.go @@ -0,0 +1,245 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "syscall" + + "github.com/pelletier/go-toml/v2" +) + +// Entry records an explicitly added local program or a verified package. +// Discovery never adds entries. Enablement is desired state; failures retain it. +type Entry struct { + ID string `toml:"id"` + Program []string `toml:"program"` + Directory string `toml:"directory,omitempty"` + Enabled bool `toml:"enabled"` + Presentation bool `toml:"presentation"` + Interaction bool `toml:"interaction,omitempty"` + Repository string `toml:"repository,omitempty"` + RepositoryID string `toml:"repository-id,omitempty"` + LocalID string `toml:"local-id,omitempty"` + Name string `toml:"name,omitempty"` + Version string `toml:"package-version,omitempty"` + PackageDirectory string `toml:"package-directory,omitempty"` + PackageSHA256 string `toml:"package-sha256,omitempty"` + RepositoryRevision string `toml:"repository-revision,omitempty"` +} + +func (entry Entry) Spec() Spec { + return Spec{ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction} +} + +type registryDocument struct { + Version int `toml:"version"` + Entries []Entry `toml:"tentacles"` + Repositories []Repository `toml:"repositories,omitempty"` +} + +// Registry is owned by the reader lifecycle worker, independently of profiles. +// Its optimistic revision prevents overwriting another editor's changes. +type Registry struct { + Path string + original []byte + loaded bool + repositories []Repository +} + +var ErrRegistrySync = errors.New("tentacle registry was replaced but its directory could not be synced") + +func RegistryPath(settingsPath string) string { + return filepath.Join(filepath.Dir(settingsPath), "tentacles.toml") +} + +func CloneEntries(entries []Entry) []Entry { + result := append([]Entry(nil), entries...) + for index := range result { + result[index].Program = append([]string(nil), result[index].Program...) + } + return result +} + +func ValidateEntries(entries []Entry) error { + if len(entries) > DefaultLimits().Processes { + return ErrLimit + } + seen := make(map[string]bool) + packageDirectories := make(map[string]bool) + for _, entry := range entries { + if err := validate_spec(entry.Spec()); err != nil { + return err + } + if entry.Repository != "" { + origin, err := RepositoryURL(entry.Repository) + if err != nil || origin != entry.Repository || !metadata_id(entry.RepositoryID) || !metadata_id(entry.LocalID) { + return errors.New("invalid installed tentacle source identity") + } + } else if entry.RepositoryID != "" || entry.LocalID != "" { + return errors.New("tentacle source identity requires a repository origin") + } + if !metadata_text(entry.Name, false) || !metadata_text(entry.Version, false) { + return errors.New("invalid installed tentacle display metadata") + } + if seen[entry.ID] { + return errors.New("duplicate tentacle ID") + } + if entry.PackageDirectory != "" { + relativeProgram, err := filepath.Rel(entry.PackageDirectory, entry.Program[0]) + if entry.Repository == "" || !filepath.IsAbs(entry.PackageDirectory) || + filepath.Clean(entry.PackageDirectory) != entry.PackageDirectory || entry.Directory != entry.PackageDirectory || + !valid_digest(entry.PackageSHA256) || !valid_revision(entry.RepositoryRevision) || + entry.ID != PackageID(Repository{URL: entry.Repository, ID: entry.RepositoryID}, entry.LocalID) || + err != nil || !package_path(filepath.ToSlash(relativeProgram)) || packageDirectories[entry.PackageDirectory] { + return errors.New("invalid installed package lock") + } + packageDirectories[entry.PackageDirectory] = true + } else if entry.PackageSHA256 != "" || entry.RepositoryRevision != "" { + return errors.New("package lock requires a package directory") + } + seen[entry.ID] = true + } + data, err := toml.Marshal(registryDocument{Version: 1, Entries: entries}) + if err == nil && len(data) > MaxFrameBytes { + return ErrLimit + } + return err +} + +func read_registry(path string) ([]byte, error) { + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("tentacle registry must be a regular file") + } + data, err := io.ReadAll(io.LimitReader(file, MaxFrameBytes+1)) + if len(data) > MaxFrameBytes { + return nil, ErrLimit + } + return data, err +} + +func (registry *Registry) Load() ([]Entry, error) { + registry.loaded = false + data, err := read_registry(registry.Path) + if err != nil { + return nil, err + } + var document registryDocument + if data != nil { + decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields() + if err := decoder.Decode(&document); err != nil { + return nil, err + } + if document.Version != 1 { + return nil, errors.New("unsupported tentacle registry version") + } + if err := ValidateEntries(document.Entries); err != nil { + return nil, err + } + if err := ValidateRepositories(document.Repositories); err != nil { + return nil, err + } + } + registry.original, registry.loaded = data, true + registry.repositories = CloneRepositories(document.Repositories) + return CloneEntries(document.Entries), nil +} + +// Save validates before touching disk, locks cooperating writers, and atomically +// replaces an owner-only file. A failed load can never be overwritten. +func (registry *Registry) Save(entries []Entry) error { + return registry.SaveState(entries, registry.repositories) +} + +func (registry *Registry) Repositories() []Repository { + return CloneRepositories(registry.repositories) +} + +func (registry *Registry) SaveState(entries []Entry, repositories []Repository) error { + if !registry.loaded { + return errors.New("tentacle registry has not been loaded successfully") + } + if err := ValidateEntries(entries); err != nil { + return err + } + if err := ValidateRepositories(repositories); err != nil { + return err + } + data, err := toml.Marshal(registryDocument{Version: 1, Entries: entries, Repositories: repositories}) + if err != nil { + return err + } + if len(data) > MaxFrameBytes { + return ErrLimit + } + directory := filepath.Dir(registry.Path) + if err := os.MkdirAll(directory, 0700); err != nil { + return err + } + lock, err := os.OpenFile(registry.Path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600) + if err != nil { + return err + } + defer lock.Close() + if info, err := lock.Stat(); err != nil || !info.Mode().IsRegular() { + return errors.New("tentacle registry lock must be a regular file") + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return errors.New("tentacle registry is being edited") + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + current, err := read_registry(registry.Path) + if err != nil { + return err + } + if !bytes.Equal(current, registry.original) || (current == nil) != (registry.original == nil) { + return errors.New("tentacle registry changed outside the reader; restart before editing") + } + temporary, err := os.CreateTemp(directory, ".tentacles-*.toml") + if err != nil { + return err + } + defer os.Remove(temporary.Name()) + defer temporary.Close() + if _, err := temporary.Write(data); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + dir, err := os.Open(directory) + if err != nil { + return err + } + defer dir.Close() + if err := os.Rename(temporary.Name(), registry.Path); err != nil { + return err + } + registry.original = data + registry.repositories = CloneRepositories(repositories) + if err := dir.Sync(); err != nil { + return fmt.Errorf("%w: %w", ErrRegistrySync, err) + } + return nil +} diff --git a/internal/tentacle/registry_test.go b/internal/tentacle/registry_test.go new file mode 100644 index 0000000..0822eb7 --- /dev/null +++ b/internal/tentacle/registry_test.go @@ -0,0 +1,105 @@ +package tentacle + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestRegistryEmptyRoundTripAndConflict(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings", "tentacles.toml") + registry := &Registry{Path: path} + entries, err := registry.Load() + if err != nil || len(entries) != 0 { + t.Fatalf("fresh registry: %v %v", entries, err) + } + if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) { + t.Fatal("reading defaults created files") + } + entries = []Entry{{ID: "example", Program: []string{"/usr/bin/example", "literal $(data)"}, Enabled: true, Presentation: true}} + if err := registry.Save(entries); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0600 { + t.Fatalf("permissions: %v", info.Mode()) + } + other := &Registry{Path: path} + loaded, err := other.Load() + if err != nil || !reflect.DeepEqual(entries, loaded) { + t.Fatalf("round trip: %v %v", loaded, err) + } + entries[0].Enabled = false + if err := registry.Save(entries); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + if err := other.Save(nil); err == nil { + t.Fatal("stale writer replaced current registry") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("failed save changed file") + } + if err := registry.Save(nil); err != nil { + t.Fatal(err) + } + loaded, err = other.Load() + if err != nil || len(loaded) != 0 { + t.Fatalf("remove all: %v %v", loaded, err) + } +} + +func TestRegistryRejectsInvalidWithoutReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "tentacles.toml") + for _, data := range []string{ + "", "version = 2\n", "version = 1\nunknown = true\n", + "version = 1\n[[tentacles]]\nid = 'relative'\nprogram = ['relative']\n", + "version = 1\n" + strings.Repeat("[[tentacles]]\nid = 'duplicate'\nprogram = ['/usr/bin/example']\n", 2), + strings.Repeat("x", MaxFrameBytes+1), + } { + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + registry := &Registry{Path: path} + if _, err := registry.Load(); err == nil { + t.Fatalf("accepted invalid registry: %.80s", data) + } + if err := registry.Save(nil); err == nil { + t.Fatal("saved after failed load") + } + after, _ := os.ReadFile(path) + if string(after) != data { + t.Fatal("invalid registry overwritten") + } + } + link := path + ".link" + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err := (&Registry{Path: link}).Load(); err == nil { + t.Fatal("accepted symlink") + } +} + +func TestRegistryInteractionGrantRoundTrip(t *testing.T) { + registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")} + if _, err := registry.Load(); err != nil { + t.Fatal(err) + } + entry := Entry{ID: "wormhole", Program: []string{"/tmp/wormhole-tentacle"}, Enabled: true, Presentation: true, Interaction: true} + if err := registry.Save([]Entry{entry}); err != nil { + t.Fatal(err) + } + loaded, err := registry.Load() + if err != nil || len(loaded) != 1 || !loaded[0].Spec().Interaction { + t.Fatal(loaded, err) + } + entry.Presentation = false + if err := registry.Save([]Entry{entry}); err == nil { + t.Fatal("interaction without presentation accepted") + } +} diff --git a/internal/tentacle/repository.go b/internal/tentacle/repository.go new file mode 100644 index 0000000..009e090 --- /dev/null +++ b/internal/tentacle/repository.go @@ -0,0 +1,285 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path" + "slices" + "strings" + "syscall" + "time" + "unicode" + + "github.com/pelletier/go-toml/v2" +) + +// Repository is a validated discovery snapshot, never an installed package. +// Catalog refresh does not run code or change installed versions or grants. +type Repository struct { + URL string `toml:"url"` + ID string `toml:"id"` + Name string `toml:"name"` + Description string `toml:"description"` + Revision string `toml:"revision"` + Tentacles []Manifest `toml:"tentacles"` +} + +// Manifest is cached discovery metadata. Releases describe immutable packages; +// launch permissions are read and validated again from the selected package. +type Manifest struct { + FormatVersion int `toml:"format-version"` + ID string `toml:"id"` + Name string `toml:"name"` + Description string `toml:"description"` + Version string `toml:"version"` + Releases []Release `toml:"releases,omitempty"` + Capabilities []string `toml:"capabilities,omitempty"` +} + +type Release struct { + Platform string `toml:"platform"` + Architecture string `toml:"architecture"` + URL string `toml:"url"` + SHA256 string `toml:"sha256"` +} + +func RepositoryURL(text string) (string, error) { + value, err := url.Parse(strings.TrimSpace(text)) + if err != nil || value.Scheme != "https" || value.Hostname() == "" || value.User != nil || value.ForceQuery || value.RawQuery != "" || strings.Contains(text, "#") || value.Opaque != "" { + return "", errors.New("Use an HTTPS Git repository URL without credentials, a query, or a fragment.") + } + // Preserve the server's repository path: ordinary Git hosting may require + // the .git suffix even when the display label omits it. + value.Path = strings.TrimRight(value.Path, "/") + if value.Path == "" || value.Path == "/" || path.Clean(value.Path) != value.Path || strings.ContainsAny(value.Path, "\\\x00\r\n") || len(text) > 2048 { + return "", errors.New("Use a URL with a valid Git repository path.") + } + value.Host = strings.ToLower(value.Host) + value.RawPath = "" + return value.String(), nil +} + +func RepositoryLabel(origin string) string { + value, err := url.Parse(origin) + if err != nil { + return origin + } + return strings.TrimSuffix(strings.Trim(value.Path, "/"), ".git") +} + +func CloneRepositories(repositories []Repository) []Repository { + result := slices.Clone(repositories) + for index := range result { + result[index].Tentacles = slices.Clone(result[index].Tentacles) + for item := range result[index].Tentacles { + result[index].Tentacles[item].Releases = slices.Clone(result[index].Tentacles[item].Releases) + result[index].Tentacles[item].Capabilities = slices.Clone(result[index].Tentacles[item].Capabilities) + } + } + return result +} + +func metadata_text(value string, required bool) bool { + return (!required || strings.TrimSpace(value) != "") && len(value) <= 4096 && !strings.ContainsFunc(value, func(character rune) bool { return unicode.IsControl(character) }) +} + +func metadata_id(value string) bool { + if len(value) == 0 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' { + return false + } + for _, character := range value { + if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '.') { + return false + } + } + return true +} + +func ValidateRepositories(repositories []Repository) error { + if len(repositories) > 32 { + return ErrLimit + } + seen := make(map[string]bool) + count := 0 + for _, repository := range repositories { + origin, err := RepositoryURL(repository.URL) + if err != nil || origin != repository.URL || seen[origin] || !metadata_id(repository.ID) || !metadata_text(repository.Name, true) || !metadata_text(repository.Description, false) || len(repository.Revision) != 40 && len(repository.Revision) != 64 { + return errors.New("invalid or duplicate tentacle repository metadata") + } + for _, character := range repository.Revision { + if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') { + return errors.New("invalid repository revision") + } + } + seen[origin] = true + ids := make(map[string]bool) + for _, manifest := range repository.Tentacles { + count++ + if count > 256 { + return ErrLimit + } + if manifest.FormatVersion != 1 || !metadata_id(manifest.ID) || ids[manifest.ID] || !metadata_text(manifest.Name, true) || !metadata_text(manifest.Description, false) || !metadata_text(manifest.Version, true) { + return errors.New("invalid or duplicate tentacle manifest metadata") + } + ids[manifest.ID] = true + if len(manifest.Capabilities) > 16 { + return ErrLimit + } + capabilities := make(map[string]bool) + for _, capability := range manifest.Capabilities { + if !metadata_id(capability) || capabilities[capability] { + return errors.New("invalid or duplicate tentacle capability metadata") + } + capabilities[capability] = true + } + if err := validate_releases(manifest.Releases); err != nil { + return err + } + } + } + return nil +} + +// read_repository uses regular blobs from one immutable revision. Tests supply +// this reader directly; the public fetch path only permits HTTPS origins. +func read_repository(origin, revision string, read func(string) ([]byte, error)) (Repository, error) { + repository := Repository{URL: origin, Revision: revision} + var metadata struct { + FormatVersion int `toml:"format-version"` + ID string `toml:"id"` + Name string `toml:"name"` + Description string `toml:"description"` + } + var catalog struct { + FormatVersion int `toml:"format-version"` + Tentacles []struct { + ID string `toml:"id"` + Manifest string `toml:"manifest"` + Releases []Release `toml:"releases"` + } `toml:"tentacles"` + } + total := 0 + decode := func(filename string, value any) error { + data, err := read(filename) + if err != nil { + return fmt.Errorf("Cannot read %s from the repository.", filename) + } + total += len(data) + if total > MaxFrameBytes { + return ErrLimit + } + if err := toml.Unmarshal(data, value); err != nil { + return fmt.Errorf("Invalid TOML in %s.", filename) + } + return nil + } + if err := decode("repository.toml", &metadata); err != nil { + return repository, err + } + if metadata.FormatVersion != 1 { + return repository, errors.New("Unsupported repository.toml format-version; expected 1.") + } + repository.ID, repository.Name, repository.Description = metadata.ID, metadata.Name, metadata.Description + if err := decode("catalog.toml", &catalog); err != nil { + return repository, err + } + if catalog.FormatVersion != 1 { + return repository, errors.New("Unsupported catalog.toml format-version; expected 1.") + } + if len(catalog.Tentacles) > 256 { + return repository, ErrLimit + } + for _, item := range catalog.Tentacles { + parts := strings.Split(item.Manifest, "/") + if len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || parts[1] != "tentacle.toml" || strings.ContainsAny(item.Manifest, "\\\x00\r\n:") || !metadata_text(item.Manifest, true) { + return repository, errors.New("Catalog manifests must be inside direct top-level tentacle folders.") + } + var manifest Manifest + if err := decode(item.Manifest, &manifest); err != nil { + return repository, err + } + if manifest.ID != item.ID { + return repository, errors.New("Catalog and manifest tentacle IDs do not match.") + } + // Release locations belong to the catalog, not to a package manifest. + manifest.Releases = slices.Clone(item.Releases) + repository.Tentacles = append(repository.Tentacles, manifest) + } + return repository, ValidateRepositories([]Repository{repository}) +} + +type boundedGitOutput struct{ bytes.Buffer } + +func (output *boundedGitOutput) Write(data []byte) (int, error) { + if output.Len()+len(data) > MaxFrameBytes { + return 0, ErrLimit + } + return output.Buffer.Write(data) +} + +func repository_git(ctx context.Context, directory string, arguments ...string) ([]byte, error) { + command := exec.CommandContext(ctx, "git", append([]string{"--literal-pathspecs", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "gc.auto=0"}, arguments...)...) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } + command.WaitDelay = time.Second + command.Dir = directory + for _, variable := range os.Environ() { + if !strings.HasPrefix(variable, "GIT_") && !strings.HasPrefix(variable, "HOME=") && !strings.HasPrefix(variable, "XDG_CONFIG_HOME=") { + command.Env = append(command.Env, variable) + } + } + command.Env = append(command.Env, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "HOME="+directory, "XDG_CONFIG_HOME="+directory) + var output boundedGitOutput + command.Stdout = &output + // No repository-controlled diagnostic text enters Preferences or the reader. + if err := command.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errors.New("Git could not read this repository; check the URL and connection.") + } + return output.Bytes(), nil +} + +func FetchRepository(ctx context.Context, origin string) (Repository, error) { + origin, err := RepositoryURL(origin) + if err != nil { + return Repository{}, err + } + directory, err := os.MkdirTemp("", "cthulhu-repository-") + if err != nil { + return Repository{}, err + } + defer os.RemoveAll(directory) + if _, err := repository_git(ctx, directory, "init", "--bare", "--template="); err != nil { + return Repository{}, err + } + if _, err := repository_git(ctx, directory, "fetch", "--depth=1", "--no-tags", "--no-recurse-submodules", "--", origin, "HEAD"); err != nil { + return Repository{}, err + } + return read_git_repository(ctx, directory, origin) +} + +func read_git_repository(ctx context.Context, directory, origin string) (Repository, error) { + revision, err := repository_git(ctx, directory, "rev-parse", "--verify", "FETCH_HEAD^{commit}") + if err != nil { + return Repository{}, err + } + commit := strings.TrimSpace(string(revision)) + return read_repository(origin, commit, func(filename string) ([]byte, error) { + // Reject symlinks, trees and submodules before reading any metadata. + mode, err := repository_git(ctx, directory, "ls-tree", commit, "--", filename) + if err != nil || !(bytes.HasPrefix(mode, []byte("100644 blob ")) || bytes.HasPrefix(mode, []byte("100755 blob "))) { + return nil, errors.New("metadata must be a regular blob") + } + return repository_git(ctx, directory, "show", commit+":"+filename) + }) +} diff --git a/internal/tentacle/repository_test.go b/internal/tentacle/repository_test.go new file mode 100644 index 0000000..764a747 --- /dev/null +++ b/internal/tentacle/repository_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package tentacle + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func repository_files() map[string]string { + return map[string]string{ + "repository.toml": "format-version = 1\nid = 'example.tentacles'\nname = 'Example tentacles'\ndescription = 'Test catalog'\nmaintainer = 'Maintainer'\n", + "catalog.toml": "format-version = 1\n[[tentacles]]\nid = 'echo'\nmanifest = 'echo/tentacle.toml'\n", + "echo/tentacle.toml": "format-version = 1\nid = 'echo'\nname = 'Echo'\nversion = '0.1'\ndescription = 'An example'\ncapabilities = ['presentation']\n", + } +} +func read_repository_fixture(files map[string]string) (Repository, error) { + return read_repository("https://example.org/team/tentacles", strings.Repeat("a", 40), func(filename string) ([]byte, error) { + data, found := files[filename] + if !found { + return nil, os.ErrNotExist + } + return []byte(data), nil + }) +} +func TestRepositoryMetadataDiscoveryValidation(t *testing.T) { + repository, err := read_repository_fixture(repository_files()) + if err != nil || repository.ID != "example.tentacles" || len(repository.Tentacles) != 1 || repository.Tentacles[0].Name != "Echo" { + t.Fatalf("discovery: %+v %v", repository, err) + } + for _, problem := range []string{"missing root", "version", "missing manifest", "traversal", "nested", "mismatch", "duplicate", "oversized", "invalid id"} { + t.Run(problem, func(t *testing.T) { + files := repository_files() + switch problem { + case "missing root": + delete(files, "repository.toml") + case "version": + files["repository.toml"] = strings.Replace(files["repository.toml"], "format-version = 1", "format-version = 2", 1) + case "missing manifest": + delete(files, "echo/tentacle.toml") + case "traversal": + files["catalog.toml"] = strings.Replace(files["catalog.toml"], "echo/tentacle.toml", "../tentacle.toml", 1) + case "nested": + files["catalog.toml"] = strings.Replace(files["catalog.toml"], "echo/tentacle.toml", "a/b/tentacle.toml", 1) + case "mismatch": + files["echo/tentacle.toml"] = strings.Replace(files["echo/tentacle.toml"], "id = 'echo'", "id = 'different'", 1) + case "duplicate": + files["catalog.toml"] += "[[tentacles]]\nid='echo'\nmanifest='echo/tentacle.toml'\n" + case "oversized": + files["repository.toml"] += "#" + strings.Repeat("x", MaxFrameBytes) + case "invalid id": + files["echo/tentacle.toml"] = strings.Replace(files["echo/tentacle.toml"], "id = 'echo'", "id = 'Echo!'", 1) + } + if _, err := read_repository_fixture(files); err == nil { + t.Fatal("accepted invalid metadata") + } + }) + } +} +func TestRepositoryURLAndLabel(t *testing.T) { + origin, err := RepositoryURL(" https://EXAMPLE.org/team/tentacles.git/ ") + if err != nil || origin != "https://example.org/team/tentacles.git" || RepositoryLabel(origin) != "team/tentacles" { + t.Fatalf("URL %q %v", origin, err) + } + for _, origin := range []string{"file:///tmp/source", "http://example.org/team/repo", "ssh://example.org/team/repo", "https://Username@example.org/team/repo", "https://example.org/", "https://example.org/team/../repo", "https://example.org/team/repo?ref=main", "https://example.org/team/repo#ref", "https://example.org/team/%00repo"} { + if _, err := RepositoryURL(origin); err == nil { + t.Fatalf("accepted %q", origin) + } + } +} +func TestRepositorySnapshotPersistenceAndIsolation(t *testing.T) { + repository, err := read_repository_fixture(repository_files()) + if err != nil { + t.Fatal(err) + } + registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")} + entries, err := registry.Load() + if err != nil { + t.Fatal(err) + } + if err := registry.SaveState(entries, []Repository{repository}); err != nil { + t.Fatal(err) + } + reopened := &Registry{Path: registry.Path} + if _, err := reopened.Load(); err != nil { + t.Fatal(err) + } + cached := reopened.Repositories() + if len(cached) != 1 || cached[0].Tentacles[0].Name != "Echo" { + t.Fatal(cached) + } + cached[0].Tentacles[0].Name = "mutated" + if reopened.Repositories()[0].Tentacles[0].Name != "Echo" { + t.Fatal("shared snapshot storage") + } + if err := reopened.Save(nil); err != nil { + t.Fatal(err) + } + if _, err := registry.Load(); err != nil || len(registry.Repositories()) != 1 { + t.Fatalf("entry save dropped catalog: %v", err) + } +} +func TestRepositoryGitIgnoresInheritedConfigAndReadsBlobs(t *testing.T) { + directory := t.TempDir() + // A poisoned inherited config must not affect even Git initialization. + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.bare") + t.Setenv("GIT_CONFIG_VALUE_0", "invalid") + ctx := context.Background() + if _, err := repository_git(ctx, directory, "init", "--bare", "--template="); err != nil { + t.Fatal(err) + } + source := t.TempDir() + for name, data := range repository_files() { + filename := filepath.Join(source, name) + if err := os.MkdirAll(filepath.Dir(filename), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + // Populate a local test snapshot without enabling file transport in the + // production fetch path or contacting a real repository. + run := func(args ...string) string { + command := exec.Command("git", args...) + command.Dir = source + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + source, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Maintainer", "GIT_AUTHOR_EMAIL=maintainer@example.invalid", "GIT_COMMITTER_NAME=Maintainer", "GIT_COMMITTER_EMAIL=maintainer@example.invalid"} + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git: %s %v", output, err) + } + return strings.TrimSpace(string(output)) + } + run("init", "--template=") + run("add", ".") + run("commit", "-m", "Fixture") + setHead := func() { + t.Helper() + if err := os.WriteFile(filepath.Join(source, ".git", "FETCH_HEAD"), []byte(run("rev-parse", "HEAD")+"\n"), 0600); err != nil { + t.Fatal(err) + } + } + setHead() + repository, err := read_git_repository(ctx, source, "https://example.org/team/tentacles") + if err != nil || len(repository.Tentacles) != 1 { + t.Fatalf("Git discovery: %+v %v", repository, err) + } + revision := run("rev-parse", "HEAD") + data, err := repository_git(ctx, source, "show", revision+":repository.toml") + if err != nil || !strings.Contains(string(data), "example.tentacles") { + t.Fatalf("read blob: %s %v", data, err) + } + // A regular-looking root symlink must be rejected rather than interpreted + // as metadata, including when it references an otherwise valid file. + if err := os.Remove(filepath.Join(source, "repository.toml")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("echo/tentacle.toml", filepath.Join(source, "repository.toml")); err != nil { + t.Fatal(err) + } + run("add", ".") + run("commit", "-m", "Symlink fixture") + setHead() + if _, err := read_git_repository(ctx, source, "https://example.org/team/tentacles"); err == nil { + t.Fatal("accepted symlink metadata") + } + cancelled, cancel := context.WithCancel(ctx) + cancel() + if _, err := repository_git(cancelled, source, "status"); err == nil { + t.Fatal("ignored cancellation") + } +} diff --git a/internal/tentacle/sdk_test.go b/internal/tentacle/sdk_test.go new file mode 100644 index 0000000..05ea778 --- /dev/null +++ b/internal/tentacle/sdk_test.go @@ -0,0 +1,252 @@ +package tentacle + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" +) + +func TestSDKChild(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "sdk-child" { + return + } + cancelFile := os.Args[len(os.Args)-1] + started := make(chan struct{}) + var once sync.Once + commands := []sdk.Command{ + {ID: "echo", Title: "Echo", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { return args, nil }}, + {ID: "fail", Title: "Fail", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { panic("private handler details") }}, + {ID: "large", Title: "Large", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + return strings.Repeat("x", MaxFrameBytes), nil + }}, + {ID: "wait", Title: "Wait", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + once.Do(func() { close(started) }) + <-ctx.Done() + return nil, os.WriteFile(cancelFile, []byte("cancelled"), 0600) + }}, + {ID: "started", Title: "Started", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + select { + case <-started: + return true, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }}, + } + if filepath.Base(cancelFile) == "presentation" { + commands = append(commands, sdk.Command{ID: "present", Title: "Present", Presentation: true, + Handle: func(ctx context.Context, args json.RawMessage) (any, error) { + return sdk.Presentation{Speech: "Hello, é猫😀é", Braille: "Display é猫😀é", Uninterruptible: true}, nil + }}) + } + if sdk.Serve(os.Stdin, os.Stdout, commands) != nil { + os.Exit(1) + } + os.Exit(0) +} + +func sdk_program(t *testing.T, language, cancelFile string) []string { + t.Helper() + if language == "go" { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return []string{executable, "-test.run=^TestSDKChild$", "--", "sdk-child", cancelFile} + } + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("Python 3 not installed") + } + peer, err := filepath.Abs("testdata/sdk_peer.py") + if err != nil { + t.Fatal(err) + } + return []string{python, "-B", peer, cancelFile} +} + +func TestSDKHostConformance(t *testing.T) { + for _, language := range []string{"go", "python"} { + for _, ending := range []string{"disable", "cancel"} { + t.Run(language+"/"+ending, func(t *testing.T) { + cancelFile := filepath.Join(t.TempDir(), "cancelled") + limits := DefaultLimits() + limits.RequestTimeout = 5 * time.Second + manager := test_manager(t, limits, nil) + session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: sdk_program(t, language, cancelFile)}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if state, err := session.Status(); state != Ready || err != nil { + t.Fatalf("%s: %v", state, err) + } + if _, err := session.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrUnavailable) { + t.Fatal("preactivation dispatch", err) + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + invoke := func(command string, args json.RawMessage) Result { + t.Helper() + pending, err := session.Invoke(context.Background(), command, args) + if err != nil { + t.Fatal(err) + } + return wait_result(t, pending) + } + for _, raw := range []string{`{"text":"é猫😀é","nested":[true,null,42]}`, `null`, `[1,"two"]`} { + result := invoke("echo", json.RawMessage(raw)) + if result.Err != nil || string(result.Value) != raw || result.Session != session.Identity() { + t.Fatalf("round trip: %+v", result) + } + } + // Fill the default host window repeatedly. Responses can arrive + // out of order; each caller must receive its own arguments. + for round := 0; round < 20; round++ { + replies := make([]<-chan Result, 8) + for index := range replies { + args, _ := json.Marshal(map[string]int{"index": index}) + replies[index], err = session.Invoke(context.Background(), "echo", args) + if err != nil { + t.Fatal(err) + } + } + for index, reply := range replies { + result := wait_result(t, reply) + var value struct { + Index int `json:"index"` + } + if result.Err != nil || json.Unmarshal(result.Value, &value) != nil || value.Index != index { + t.Fatalf("correlation: %+v", result) + } + } + } + for _, command := range []string{"fail", "large"} { + result := invoke(command, nil) + var rpcError *RPCError + if !errors.As(result.Err, &rpcError) || rpcError.Code != -32603 || strings.Contains(rpcError.Message, "private") { + t.Fatalf("unsafe error: %+v", result) + } + } + if result := invoke("echo", nil); result.Err != nil { + t.Fatal("session failed after handler error", result.Err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pending, err := session.Invoke(ctx, "wait", nil) + if err != nil { + t.Fatal(err) + } + if result := invoke("started", nil); result.Err != nil || string(result.Value) != "true" { + t.Fatal("concurrent handler failed", result) + } + if ending == "disable" { + session.Disable() + } else { + cancel() + } + if result := wait_result(t, pending); result.Err == nil { + t.Fatal("late success", result) + } + wait_signal(t, session.Done()) + if ending == "disable" { + if data, err := os.ReadFile(cancelFile); err != nil || string(data) != "cancelled" { + t.Fatalf("SDK did not cancel handler: %q, %v", data, err) + } + } + }) + } + } +} + +func TestSDKPresentationGrantAndRevocation(t *testing.T) { + for _, language := range []string{"go", "python"} { + for _, grant := range []bool{false, true} { + t.Run(language+"/grant="+strconv.FormatBool(grant), func(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + program := sdk_program(t, language, filepath.Join(t.TempDir(), "presentation")) + session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: program, Presentation: grant}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + state, err := session.Status() + if !grant { + if state != Failed || err == nil { + t.Fatal("ungranted presentation registration succeeded") + } + return + } + if state != Ready || err != nil { + t.Fatalf("%s: %v", state, err) + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + pending, err := session.Invoke(context.Background(), "present", nil) + if err != nil { + t.Fatal(err) + } + result := wait_result(t, pending) + value, err := tentaclewire.DecodePresentation(result.Value) + if result.Err != nil || err != nil || value.Speech != "Hello, é猫😀é" || value.Braille != "Display é猫😀é" || !value.Uninterruptible { + t.Fatalf("presentation: %+v, %v", result, err) + } + if session.OutputContext().Err() != nil { + t.Fatal("reply completion revoked output") + } + session.Disable() + if session.OutputContext().Err() == nil { + t.Fatal("disable left buffered output live") + } + }) + } + } +} + +func TestSDKRejectsInvalidHostMessages(t *testing.T) { + initialize := `{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocol":{"major":1,"minor":0},"session":"test","apis":["commands"],"capabilities":["commands"]}}` + "\n" + activate := `{"jsonrpc":"2.0","method":"initialized","params":{"session":"test"}}` + "\n" + invoke := `{"jsonrpc":"2.0","id":"2","method":"command.invoke","params":{"command":"echo","arguments":{}}}` + "\n" + cases := map[string]string{ + "before activation": initialize + invoke, + "wrong session": initialize + strings.Replace(activate, "test", "other", 1), + "null version": strings.Replace(initialize, `"minor":0`, `"minor":null`, 1), + "null ID": `{"jsonrpc":"2.0","id":null,"method":"shutdown"}` + "\n", + "wrong version": strings.Replace(initialize, `"major":1`, `"major":2`, 1), + "duplicate registration": initialize + initialize, + "duplicate request": initialize + activate + invoke + invoke, + "duplicate key": `{"jsonrpc":"2.0","jsonrpc":"2.0","method":"shutdown"}` + "\n", + "oversize": strings.Repeat("x", MaxFrameBytes+1) + "\n", + "unterminated": strings.TrimSuffix(initialize, "\n"), + } + for _, language := range []string{"go", "python"} { + for name, input := range cases { + t.Run(language+"/"+name, func(t *testing.T) { + program := sdk_program(t, language, filepath.Join(t.TempDir(), "cancelled")) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, program[0], program[1:]...) + command.Stdin = strings.NewReader(input) + if err := command.Run(); err == nil { + t.Fatal("invalid input accepted") + } + if ctx.Err() != nil { + t.Fatal("SDK hung on invalid input") + } + }) + } + } +} diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go new file mode 100644 index 0000000..e9b726e --- /dev/null +++ b/internal/tentacle/session.go @@ -0,0 +1,290 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "sync" + "time" +) + +type State string + +const ( + Starting State = "starting" + Ready State = "ready" + Active State = "active" + Stopping State = "stopping" + Stopped State = "stopped" + Failed State = "failed" +) + +// Result is never delivered to an output backend by this package. The future +// reader output owner must revalidate context, lock, mode and interruption state +// when consuming it, including a result already buffered before cancellation. +type Result struct { + Session string + ID string + Value json.RawMessage + Err error +} + +type pendingCall struct { + ctx context.Context + cancel context.CancelFunc + stopWatch func() bool + result chan Result +} + +type outgoing struct { + id string // Empty for lifecycle and cancellation notifications. + frame []byte +} + +type Session struct { + outputContext context.Context + revokeOutput context.CancelFunc + + mutex sync.Mutex + manager *Manager + spec Spec + identity string + ctx context.Context + cancel context.CancelFunc + state State + failure error + commands []Command + bindings []BindingStatus + sequence uint64 + pending map[string]*pendingCall + outbound chan outgoing + ready chan struct{} + done chan struct{} + stop chan struct{} + revoked chan struct{} + callbacks sync.WaitGroup +} + +func (session *Session) Ready() <-chan struct{} { return session.ready } + +// Revoked closes as soon as dispatch is retired, before descendant cleanup. +func (session *Session) Revoked() <-chan struct{} { return session.revoked } +func (session *Session) Done() <-chan struct{} { return session.done } +func (session *Session) Identity() string { return session.identity } +func (session *Session) TentacleID() string { return session.spec.ID } + +// OutputContext expires synchronously on revocation, including after a reply +// has left the host's pending-call map. It never authorizes output by itself. +func (session *Session) OutputContext() context.Context { return session.outputContext } + +func (session *Session) Status() (State, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + return session.state, session.failure +} + +// Commands returns a copy of staged or active registrations. A caller may +// inspect Ready registrations but must not dispatch them before Activate. +func (session *Session) Commands() []Command { + session.mutex.Lock() + defer session.mutex.Unlock() + return clone_commands(session.commands) +} + +// Activate is the host publication barrier. The reader stages acknowledged +// native bindings before calling it; there are no external service exports. +func (session *Session) Activate() error { + session.manager.mutex.Lock() + defer session.manager.mutex.Unlock() + occupied := session.manager.occupied_bindings() + session.mutex.Lock() + defer session.mutex.Unlock() + if session.state != Ready || session.ctx.Err() != nil { + return ErrUnavailable + } + if !session.notify_locked("initialized", struct { + Session string `json:"session"` + }{session.identity}) { + return ErrBusy + } + session.bindings = resolve_bindings(session.spec.ID, session.commands, occupied) + session.state = Active + return nil +} + +func (session *Session) Invoke(ctx context.Context, command string, arguments json.RawMessage) (<-chan Result, error) { + if ctx == nil { + return nil, errors.New("nil command context") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if len(arguments) > MaxArgumentBytes { + return nil, ErrLimit + } + if len(arguments) == 0 { + arguments = json.RawMessage(`{}`) + } + if err := validate_json(arguments); err != nil { + return nil, err + } + session.mutex.Lock() + defer session.mutex.Unlock() + if session.state != Active || session.ctx.Err() != nil { + return nil, ErrUnavailable + } + known := false + for _, registered := range session.commands { + known = known || registered.ID == command + } + if !known { + return nil, ErrUnavailable + } + return session.request_locked(ctx, "command.invoke", struct { + Command string `json:"command"` + Arguments json.RawMessage `json:"arguments"` + }{command, arguments}, session.manager.limits.RequestTimeout) +} + +func (session *Session) request_locked(parent context.Context, method string, params any, timeout time.Duration) (<-chan Result, error) { + if len(session.pending) >= session.manager.limits.PendingPerProcess { + return nil, ErrBusy + } + select { + case session.manager.pending <- struct{}{}: + default: + return nil, ErrBusy + } + session.sequence++ + id := strconv.FormatUint(session.sequence, 10) + frame, err := encode_request(id, method, params) + if err != nil { + <-session.manager.pending + return nil, err + } + ctx, cancel := context.WithTimeout(parent, timeout) + call := &pendingCall{ctx: ctx, cancel: cancel, result: make(chan Result, 1)} + session.pending[id] = call + session.callbacks.Add(1) + call.stopWatch = context.AfterFunc(ctx, func() { + defer session.callbacks.Done() + session.cancel_call(id) + }) + select { + case session.outbound <- outgoing{id: id, frame: frame}: + return call.result, nil + default: + session.finish_locked(id, nil, ErrBusy) + return nil, ErrBusy + } +} + +func (session *Session) notify_locked(method string, params any) bool { + frame, err := encode_request("", method, params) + if err != nil { + return false + } + select { + case session.outbound <- outgoing{frame: frame}: + return true + default: + return false + } +} + +func (session *Session) finish_locked(id string, value json.RawMessage, err error) { + call := session.pending[id] + if call == nil { + return + } + delete(session.pending, id) + if call.stopWatch() { + session.callbacks.Done() + } + if contextError := call.ctx.Err(); contextError != nil && err == nil { + err = contextError + value = nil + } + call.cancel() + <-session.manager.pending + call.result <- Result{Session: session.identity, ID: id, Value: value, Err: err} + close(call.result) +} + +func (session *Session) cancel_call(id string) { + session.mutex.Lock() + defer session.mutex.Unlock() + call := session.pending[id] + if call == nil { + return + } + err := call.ctx.Err() + session.finish_locked(id, nil, err) + // Without a cancellation acknowledgment contract, a timed-out child could + // accumulate unlimited still-running work. Retire the whole session instead. + // Explicit cancellation follows the same conservative rule in this slice. + session.fail_locked(err) +} + +func (session *Session) accept_reply(reply response) { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.state == Stopping || session.state == Stopped || session.state == Failed { + return + } + if session.pending[reply.ID] == nil { + // Unknown/duplicate responses are not allowed to keep a flood alive. + session.fail_locked(ErrProtocol) + return + } + var err error + if reply.Error != nil { + err = reply.Error + } + session.finish_locked(reply.ID, reply.Result, err) +} + +func (session *Session) fail_locked(err error) { + if session.state == Stopping || session.state == Stopped || session.state == Failed { + return + } + session.state, session.failure = Failed, err + close(session.revoked) + session.revokeOutput() + session.commands = nil + session.bindings = nil + for id := range session.pending { + session.finish_locked(id, nil, err) + } + session.cancel() +} + +func (session *Session) fail(err error) { + session.mutex.Lock() + defer session.mutex.Unlock() + session.fail_locked(err) +} + +// Disable revokes commands and retires replies synchronously; pipe shutdown and +// process cleanup happen on the supervisor. It is safe to call repeatedly. +func (session *Session) Disable() { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.state == Stopping || session.state == Stopped || session.state == Failed { + return + } + session.state = Stopping + close(session.revoked) + session.revokeOutput() + session.commands = nil + session.bindings = nil + for id := range session.pending { + session.finish_locked(id, nil, ErrDisabled) + } + session.notify_locked("shutdown", nil) + close(session.stop) +} diff --git a/internal/tentacle/session_test.go b/internal/tentacle/session_test.go new file mode 100644 index 0000000..87012c1 --- /dev/null +++ b/internal/tentacle/session_test.go @@ -0,0 +1,565 @@ +package tentacle + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func test_manager(t testing.TB, limits Limits, overrides []input.BindingOverride) *Manager { + t.Helper() + manager, err := NewManager(limits, overrides) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := manager.Shutdown(ctx); err != nil { + t.Error(err) + } + if stats := manager.Stats(); stats != (Stats{}) { + t.Errorf("retained resources: %+v", stats) + } + }) + return manager +} + +func child_spec(t testing.TB, id, mode string) Spec { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return Spec{ID: id, Program: []string{executable, "-test.run=^TestTentacleChild$", "--", "tentacle-child:" + mode}} +} + +func wait_signal(t testing.TB, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for child lifecycle") + } +} + +func start_ready(t testing.TB, manager *Manager, id, mode string) *Session { + t.Helper() + session, err := manager.Start(context.Background(), child_spec(t, id, mode)) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if state, err := session.Status(); state != Ready || err != nil { + t.Fatalf("state %s: %v", state, err) + } + return session +} + +func start_active(t testing.TB, manager *Manager, id, mode string) *Session { + t.Helper() + session := start_ready(t, manager, id, mode) + if err := session.Activate(); err != nil { + t.Fatal(err) + } + return session +} + +func wait_result(t testing.TB, result <-chan Result) Result { + t.Helper() + select { + case reply, ok := <-result: + if !ok { + t.Fatal("missing result") + } + return reply + case <-time.After(5 * time.Second): + t.Fatal("missing bounded result") + } + return Result{} +} + +func TestActivationAndUnicodeRoundTrip(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := start_ready(t, manager, "sample", "echo") + if _, err := session.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrUnavailable) { + t.Fatal(err) + } + commands := session.Commands() + commands[0].ID = "changed" + commands[0].Bindings[0].Key = "changed" + if session.Commands()[0].ID != "echo" || session.Commands()[0].Bindings[0].Key == "changed" { + t.Fatal("registration alias") + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + if err := session.Activate(); !errors.Is(err, ErrUnavailable) { + t.Fatal("double activation", err) + } + if _, err := session.Invoke(context.Background(), "unknown", nil); !errors.Is(err, ErrUnavailable) { + t.Fatal(err) + } + arguments := json.RawMessage(`{"text":"é猫😀é"}`) + pending, err := session.Invoke(context.Background(), "echo", arguments) + if err != nil { + t.Fatal(err) + } + result := wait_result(t, pending) + if result.Err != nil || string(result.Value) != string(arguments) || result.Session != session.Identity() { + t.Fatalf("%+v", result) + } + if _, open := <-pending; open { + t.Fatal("result channel not closed") + } + session.Disable() + if len(session.Commands()) != 0 || len(session.Bindings()) != 0 { + t.Fatal("disable did not revoke") + } + if _, err := session.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrUnavailable) { + t.Fatal(err) + } + wait_signal(t, session.Done()) + if state, err := session.Status(); state != Stopped || err != nil { + t.Fatalf("state %s: %v", state, err) + } + replacement := start_active(t, manager, "sample", "echo") + if replacement.Identity() == session.Identity() { + t.Fatal("session identity reused") + } +} + +func TestRepliesCorrelateOutOfOrder(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := start_active(t, manager, "sample", "reverse") + first, err := session.Invoke(context.Background(), "echo", json.RawMessage(`1`)) + if err != nil { + t.Fatal(err) + } + second, err := session.Invoke(context.Background(), "echo", json.RawMessage(`2`)) + if err != nil { + t.Fatal(err) + } + for index, pending := range []<-chan Result{first, second} { + result := wait_result(t, pending) + if result.Err != nil || string(result.Value) != strconv.Itoa(index+1) { + t.Fatalf("misrouted response: %+v", result) + } + } +} + +func TestDisabledStagingCannotPublish(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := start_ready(t, manager, "sample", "echo") + session.Disable() + if err := session.Activate(); !errors.Is(err, ErrUnavailable) { + t.Fatal("disabled staging published", err) + } + wait_signal(t, session.Done()) + if len(session.outbound) != 0 { + t.Fatal("cleanup retained unsent frames") + } +} + +func TestBadChildrenAreContained(t *testing.T) { + for _, mode := range []string{"malformed", "oversized", "stderr", "wrong-major", "wrong-session", "duplicate-command", "invalid-binding", "unknown-response", "hang-start"} { + t.Run(mode, func(t *testing.T) { + limits := DefaultLimits() + limits.StartupTimeout = 500 * time.Millisecond + manager := test_manager(t, limits, nil) + session, err := manager.Start(context.Background(), child_spec(t, "broken", mode)) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Done()) + if state, err := session.Status(); state != Failed || err == nil || len(session.Commands()) != 0 { + t.Fatalf("state %s: %v", state, err) + } + // Failure releases capacity for a healthy tentacle. + start_active(t, manager, "healthy", "echo") + }) + } +} + +func TestRequestFailureCancellationAndDisable(t *testing.T) { + for _, mode := range []string{"crash", "duplicate-reply", "timeout", "cancel", "disable", "blocked-input"} { + t.Run(mode, func(t *testing.T) { + limits := DefaultLimits() + limits.RequestTimeout = 150 * time.Millisecond + manager := test_manager(t, limits, nil) + session := start_active(t, manager, "sample", mode) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pending, err := session.Invoke(ctx, "echo", json.RawMessage(`{"text":"sample"}`)) + if err != nil { + t.Fatal(err) + } + if mode == "cancel" { + cancel() + } + if mode == "disable" { + session.Disable() + } + result := wait_result(t, pending) + if mode != "duplicate-reply" && result.Err == nil { + t.Fatal("failed call succeeded") + } + if mode == "cancel" && !errors.Is(result.Err, context.Canceled) { + t.Fatal(result.Err) + } + if mode == "timeout" && !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatal(result.Err) + } + if mode == "disable" && !errors.Is(result.Err, ErrDisabled) { + t.Fatal(result.Err) + } + wait_signal(t, session.Done()) + if len(session.Commands()) != 0 { + t.Fatal("failure retained commands") + } + }) + } +} + +func TestAdmissionAndAggregatePendingLimits(t *testing.T) { + limits := DefaultLimits() + limits.Processes, limits.Starting, limits.PendingPerProcess, limits.PendingTotal = 2, 1, 2, 3 + manager := test_manager(t, limits, nil) + blocked, err := manager.Start(context.Background(), child_spec(t, "blocked", "hang-start")) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Start(context.Background(), child_spec(t, "second", "echo")); !errors.Is(err, ErrBusy) { + t.Fatal("startup limit", err) + } + blocked.Disable() + wait_signal(t, blocked.Done()) + first := start_active(t, manager, "first", "timeout") + second := start_active(t, manager, "second", "timeout") + if _, err := manager.Start(context.Background(), child_spec(t, "third", "echo")); !errors.Is(err, ErrBusy) { + t.Fatal("process limit", err) + } + for range 2 { + if _, err := first.Invoke(context.Background(), "echo", nil); err != nil { + t.Fatal(err) + } + } + if _, err := first.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrBusy) { + t.Fatal("per-child limit", err) + } + if _, err := second.Invoke(context.Background(), "echo", nil); err != nil { + t.Fatal(err) + } + if _, err := second.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrBusy) { + t.Fatal("aggregate limit", err) + } + if stats := manager.Stats(); stats.Pending != 3 || stats.Processes != 2 { + t.Fatalf("%+v", stats) + } + first.Disable() + if stats := manager.Stats(); stats.Pending != 1 { + t.Fatalf("pending work not revoked: %+v", stats) + } +} + +func TestParentCancellationAndConcurrentShutdown(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + ctx, cancel := context.WithCancel(context.Background()) + session, err := manager.Start(ctx, child_spec(t, "sample", "echo")) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + if err := session.Activate(); err != nil { + t.Fatal(err) + } + var workers sync.WaitGroup + for range 16 { + workers.Add(1) + go func() { + defer workers.Done() + _, _ = session.Invoke(context.Background(), "echo", nil) + session.Disable() + }() + } + cancel() + workers.Wait() + wait_signal(t, session.Done()) + if err := manager.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := manager.Start(context.Background(), child_spec(t, "after-shutdown", "echo")); !errors.Is(err, ErrUnavailable) { + t.Fatal(err) + } +} + +func TestBlockedPipeCannotBlockSubmissionOrDisable(t *testing.T) { + limits := DefaultLimits() + limits.RequestTimeout = 10 * time.Second + manager := test_manager(t, limits, nil) + session := start_active(t, manager, "sample", "blocked-input") + arguments, err := json.Marshal(strings.Repeat("x", MaxArgumentBytes-2)) + if err != nil { + t.Fatal(err) + } + submitted := make(chan error, 1) + go func() { + for range limits.PendingPerProcess { + if _, err := session.Invoke(context.Background(), "echo", arguments); err != nil { + submitted <- err + return + } + } + session.Disable() + submitted <- nil + }() + select { + case err := <-submitted: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("pipe backpressure blocked host submission or disable") + } + wait_signal(t, session.Done()) +} + +func TestLaunchValidationAndMissingExecutable(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + if _, err := manager.Start(nil, child_spec(t, "sample", "echo")); err == nil { + t.Fatal("nil context accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := manager.Start(ctx, child_spec(t, "sample", "echo")); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + for _, spec := range []Spec{ + {ID: "../unsafe", Program: []string{"/bin/true"}}, + {ID: "sample", Program: []string{"relative"}}, + {ID: "sample", Program: []string{"/bin/true", "nul\x00argument"}}, + {ID: "sample", Program: []string{"/bin/true"}, Directory: "relative"}, + } { + if _, err := manager.Start(context.Background(), spec); err == nil { + t.Fatal("invalid launch accepted") + } + } + session, err := manager.Start(context.Background(), Spec{ID: "missing", Program: []string{t.TempDir() + "/missing"}}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Done()) + if state, err := session.Status(); state != Failed || !errors.Is(err, ErrExited) { + t.Fatalf("%s: %v", state, err) + } + if stats := manager.Stats(); stats != (Stats{}) { + t.Fatalf("failed spawn retained resources: %+v", stats) + } +} + +func TestBindingConflictsLeaveCommandsAvailable(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + first := start_active(t, manager, "first", "echo") + bindings := first.Bindings() + if len(bindings) != 2 || bindings[0].Bound || bindings[0].Conflict != string(input.CycleSpeechMode) || !bindings[1].Bound { + t.Fatalf("core conflict: %+v", bindings) + } + second := start_active(t, manager, "second", "echo") + if bindings := second.Bindings(); bindings[1].Bound || bindings[1].Conflict != "tentacle/first/echo" { + t.Fatalf("tentacle conflict: %+v", bindings) + } + if len(second.Commands()) == 0 { + t.Fatal("unbound command disappeared") + } + first.Disable() + // No implicit rebinding of the existing second tentacle. + if second.Bindings()[1].Bound { + t.Fatal("silently rebound") + } + third := start_active(t, manager, "third", "echo") + if !third.Bindings()[1].Bound { + t.Fatal("revoked shortcut remains reserved") + } +} + +func TestBindingsRespectUserOverridesAndScopes(t *testing.T) { + manager := test_manager(t, DefaultLimits(), []input.BindingOverride{{ + Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.PresentTime, Keys: []string{"Cthulhu+Ctrl+F12"}, + }}) + session := start_active(t, manager, "sample", "echo") + if bindings := session.Bindings(); bindings[1].Bound || bindings[1].Conflict != string(input.PresentTime) { + t.Fatalf("user override conflict: %+v", bindings) + } + occupied := manager.occupied_bindings() + commands := []Command{{ID: "scoped", Bindings: []Binding{ + {Layout: "desktop", Scope: "global", Key: "h"}, + {Layout: "laptop", Scope: "global", Key: "Cthulhu+Ctrl+F12"}, + {Layout: "laptop", Scope: "browse", Key: "Ctrl+Cthulhu+F12"}, + }}} + bindings := resolve_bindings("scope", commands, occupied) + if bindings[0].Bound || !bindings[1].Bound || bindings[2].Bound { + t.Fatalf("scope/alias conflicts: %+v", bindings) + } +} + +// This is a private protocol fixture, not a public SDK or installable tentacle. +func TestTentacleChild(t *testing.T) { + mode, found := strings.CutPrefix(os.Args[len(os.Args)-1], "tentacle-child:") + if !found { + return + } + if mode == "descendant" { + <-time.After(time.Hour) + os.Exit(0) + } + reader := bufio.NewReader(os.Stdin) + encoder := json.NewEncoder(os.Stdout) + var heldID string + var heldValue json.RawMessage + for { + frame, err := read_frame(reader) + if err != nil { + os.Exit(0) + } + var message struct { + ID string `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if json.Unmarshal(frame, &message) != nil { + os.Exit(2) + } + reply := func(value any) { + _ = encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message.ID, "result": value}) + } + switch message.Method { + case "initialize": + switch mode { + case "hang-start": + continue + case "malformed": + fmt.Fprintln(os.Stdout, "not JSON") + continue + case "oversized": + fmt.Fprintln(os.Stdout, strings.Repeat("x", MaxFrameBytes+1)) + continue + case "stderr": + fmt.Fprint(os.Stderr, strings.Repeat("x", MaxDiagnosticBytes+1)) + continue + case "unknown-response": + _ = encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": "999", "result": nil}) + continue + } + var initialization Initialization + _ = json.Unmarshal(message.Params, &initialization) + registration := Registration{Protocol: initialization.Protocol, Session: initialization.Session, Commands: []Command{{ + ID: "echo", Title: "Echo text", Description: "Private protocol fixture", Bindings: []Binding{ + {Layout: "desktop", Scope: "global", Key: "Cthulhu+S"}, + {Layout: "desktop", Scope: "global", Key: "Cthulhu+Ctrl+F12"}, + }, + }}} + switch mode { + case "wrong-major": + registration.Protocol.Major++ + case "wrong-session": + registration.Session = "wrong" + case "duplicate-command": + registration.Commands = append(registration.Commands, registration.Commands[0]) + case "invalid-binding": + registration.Commands[0].Bindings[0].Key = "not-a-key" + } + reply(registration) + case "initialized": + if mode == "blocked-input" { + <-time.After(time.Hour) + } + case "command.invoke": + switch mode { + case "crash": + os.Exit(7) + case "timeout", "cancel", "disable": + continue + case "spawn": + executable, _ := os.Executable() + child := exec.Command(executable, "-test.run=^TestTentacleChild$", "--", "tentacle-child:descendant") + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if child.Start() != nil { + os.Exit(8) + } + reply(child.Process.Pid) + continue + } + var params struct { + Arguments json.RawMessage `json:"arguments"` + } + _ = json.Unmarshal(message.Params, ¶ms) + if mode == "reverse" && heldID == "" { + heldID, heldValue = message.ID, params.Arguments + continue + } + reply(params.Arguments) + if mode == "reverse" { + _ = encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": heldID, "result": heldValue}) + heldID, heldValue = "", nil + } + if mode == "duplicate-reply" { + reply(params.Arguments) + } + case "shutdown": + os.Exit(0) + default: + os.Exit(3) + } + } +} + +func TestDisableKillsGroupDescendantsAndJoinsPipes(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := start_active(t, manager, "sample", "spawn") + pending, err := session.Invoke(context.Background(), "echo", nil) + if err != nil { + t.Fatal(err) + } + result := wait_result(t, pending) + var pid int + if result.Err != nil || json.Unmarshal(result.Value, &pid) != nil || pid <= 1 { + t.Fatalf("bad descendant result: %+v", result) + } + session.Disable() + wait_signal(t, session.Done()) + // SIGKILL delivery/reaping is scheduled by the kernel; wait for /proc to + // report disappearance or zombie state, not an arbitrary sleep duration. + deadline := time.NewTimer(2 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if os.IsNotExist(err) { + break + } + if err != nil { + t.Fatal(err) + } + _, tail, found := strings.Cut(string(data), ") ") + if found && strings.HasPrefix(tail, "Z ") { + break + } + select { + case <-ticker.C: + case <-deadline.C: + t.Fatal("owned descendant remained running") + } + } +} diff --git a/internal/tentacle/testdata/peer.py b/internal/tentacle/testdata/peer.py new file mode 100644 index 0000000..334c4c6 --- /dev/null +++ b/internal/tentacle/testdata/peer.py @@ -0,0 +1,26 @@ +"""Private independent wire fixture. Not an SDK or installable tentacle.""" + +import json +import sys + +active = False +for line in sys.stdin.buffer: + request = json.loads(line) + method = request["method"] + if method == "initialize": + initialization = request["params"] + result = { + "protocol": initialization["protocol"], + "session": initialization["session"], + "commands": [{"id": "echo", "title": "Echo text", "description": ""}], + } + elif method == "initialized": + active = True + continue + elif method == "shutdown": + break + elif method == "command.invoke" and active: + result = request["params"]["arguments"] + else: + sys.exit(2) + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True) diff --git a/internal/tentacle/testdata/resource_peer.py b/internal/tentacle/testdata/resource_peer.py new file mode 100644 index 0000000..9aed62b --- /dev/null +++ b/internal/tentacle/testdata/resource_peer.py @@ -0,0 +1,63 @@ +"""Private cgroup fixture. Never run resource commands outside a bounded group.""" + +import json +import os +import time + + +allocations = [] +for line in iter(input, None): + message = json.loads(line) + method = message["method"] + if method == "initialize": + params = message["params"] + result = { + "protocol": params["protocol"], + "session": params["session"], + "commands": [{"id": "echo", "title": "Resource fixture", "description": ""}], + } + elif method == "initialized": + continue + elif method == "shutdown": + break + elif method == "command.invoke": + action = message["params"]["arguments"].get("action", "location") + if action == "detach": + childPid = os.fork() + if childPid == 0: + os.setsid() + time.sleep(300) + os._exit(0) + # Synchronize with the detach; inherited stdout stays open. + while os.getsid(childPid) != childPid: + time.sleep(0.001) + result = childPid + elif action == "tasks": + childPids = [] + for _ in range(96): + try: + childPid = os.fork() + except BlockingIOError: + break + if childPid == 0: + time.sleep(300) + os._exit(0) + childPids.append(childPid) + result = len(childPids) + elif action == "memory": + allocation = bytearray(message["params"]["arguments"]["bytes"]) + for offset in range(0, len(allocation), 4096): + allocation[offset] = 1 + allocations.append(allocation) + result = True + elif action == "cpu": + result = True + else: + with open("/proc/self/cgroup", encoding="ascii") as source: + result = source.read().strip() + else: + raise RuntimeError("Unexpected method") + print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}), flush=True) + if method == "command.invoke" and action == "cpu": + while True: + pass diff --git a/internal/tentacle/testdata/sdk_peer.py b/internal/tentacle/testdata/sdk_peer.py new file mode 100644 index 0000000..f8128d1 --- /dev/null +++ b/internal/tentacle/testdata/sdk_peer.py @@ -0,0 +1,41 @@ +# Private conformance peer: author APIs only; no framing or lifecycle code. +import pathlib +import sys +import threading + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "sdk/python")) +from cthulhu_tentacle import Command, present, serve + +started = threading.Event() + + +def echo(cancelled, arguments): + return arguments + + +def fail(cancelled, arguments): + raise RuntimeError("private handler details") + + +def large(cancelled, arguments): + return "x" * 65536 + + +def wait(cancelled, arguments): + started.set() + cancelled.wait() + pathlib.Path(sys.argv[1]).write_text("cancelled") + + +def has_started(cancelled, arguments): + return started.wait(2) + + +commands = [Command("echo", "Echo", "", echo), Command("fail", "Fail", "", fail), + Command("large", "Large", "", large), Command("wait", "Wait", "", wait), + Command("started", "Started", "", has_started)] +if pathlib.Path(sys.argv[1]).name == "presentation": + commands.append(Command("present", "Present", "", + lambda cancelled, arguments: present(speech="Hello, é猫😀é", braille="Display é猫😀é", uninterruptible=True), + presentation=True)) +serve(commands) diff --git a/internal/tentaclewire/interaction.go b/internal/tentaclewire/interaction.go new file mode 100644 index 0000000..99f1d0e --- /dev/null +++ b/internal/tentaclewire/interaction.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import ( + "bytes" + "encoding/json" + "unicode" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/interaction" +) + +// Interaction is an opt-in command result. The host runs forms outside the +// bounded RPC deadline and invokes Next with the submitted values. Poll is a +// registered command sampled once per second while the form remains open. +// A polling reply may announce a changed status or finish the form with Done. +type Interaction struct { + Presentation + Form *interaction.Form `json:"form,omitempty"` + Next string `json:"next,omitempty"` + Poll string `json:"poll,omitempty"` + Source string `json:"source,omitempty"` // "focus" or "clipboard", supplied to Next. + Clipboard *string `json:"clipboard,omitempty"` + Done bool `json:"done,omitempty"` +} + +type Submission struct { + Activated string `json:"activated,omitempty"` + Values map[string]interaction.Value `json:"values,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` + Item *Item `json:"item,omitempty"` + Error string `json:"error,omitempty"` +} + +// Item is a single copied value, without accessible identities or native handles. +type Item struct { + Temporary bool `json:"-"` // Host-only ownership of materialized clipboard data. + Text string `json:"text,omitempty"` + Path string `json:"path,omitempty"` +} + +func DecodeInteraction(data []byte) (Interaction, error) { + var value Interaction + var fields map[string]json.RawMessage + if json.Unmarshal(data, &fields) != nil || fields == nil { + return value, ErrProtocol + } + for _, field := range fields { + if bytes.Equal(bytes.TrimSpace(field), []byte("null")) { + return value, ErrProtocol + } + } + if err := DecodeStrict(data, &value); err != nil { + return value, err + } + if value.Speech != "" || value.Braille != "" { + message, _ := json.Marshal(value.Presentation) + if _, err := DecodePresentation(message); err != nil { + return value, err + } + } else if value.Uninterruptible { + return value, ErrProtocol + } + if value.Clipboard != nil && (!utf8.ValidString(*value.Clipboard) || len(*value.Clipboard) > MaxPresentationBytes) { + return value, ErrLimit + } + if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" { + return value, ErrProtocol + } + if value.Form != nil { + for _, element := range value.Form.Elements { + if !validFormText(element.Label) || !validFormText(element.ID) { + return value, ErrProtocol + } + for _, item := range element.Items { + if !validFormText(item.Label) || !validFormText(item.ID) { + return value, ErrProtocol + } + } + } + if !validFormText(value.Form.Title) { + return value, ErrProtocol + } + if _, err := interaction.NewSession(*value.Form); err != nil { + return value, ErrProtocol + } + if value.Next == "" || value.Done || value.Source != "" { + return value, ErrProtocol + } + } else if value.Poll != "" || value.Next != "" && value.Source == "" || value.Source != "" && value.Next == "" { + return value, ErrProtocol + } + return value, nil +} + +func validFormText(text string) bool { + if !utf8.ValidString(text) { + return false + } + for _, r := range text { + if unicode.IsControl(r) { + return false + } + } + return true +} diff --git a/internal/tentaclewire/interaction_test.go b/internal/tentaclewire/interaction_test.go new file mode 100644 index 0000000..5a95704 --- /dev/null +++ b/internal/tentaclewire/interaction_test.go @@ -0,0 +1,57 @@ +package tentaclewire + +import ( + "encoding/json" + "git.stormux.org/storm/cthulhu/internal/interaction" + "strings" + "testing" +) + +func TestInteractionContract(t *testing.T) { + form := &interaction.Form{Title: "Wormhole menu", Elements: []interaction.Element{{ID: "choices", Label: "Options", Kind: interaction.List, Items: []interaction.Item{{ID: "send", Label: "Send focused item"}}}}} + for _, value := range []Interaction{ + {Form: form, Next: "choice"}, {Form: form, Next: "choice", Poll: "status"}, {Source: "focus", Next: "send"}, {Source: "clipboard", Next: "send"}, + {Presentation: Presentation{Speech: "Ready"}, Done: true}, {Done: true}, {}, + } { + data, _ := json.Marshal(value) + if _, err := DecodeInteraction(data); err != nil { + t.Fatalf("%s: %v", data, err) + } + } + for _, raw := range []string{`null`, ` null `, `{"form":null}`, `{"clipboard":null}`, `{"unknown":true}`, `{"source":"desktop","next":"send"}`, `{"source":"focus"}`, `{"next":"send"}`, `{"poll":"status"}`, `{"speech":"bad\u0000"}`, `{"uninterruptible":true}`} { + if _, err := DecodeInteraction([]byte(raw)); err == nil { + t.Fatal("accepted", raw) + } + } + for _, value := range []Interaction{{Form: form}, {Form: form, Next: "choice", Done: true}, {Form: form, Next: "choice", Source: "focus"}} { + data, _ := json.Marshal(value) + if _, err := DecodeInteraction(data); err == nil { + t.Fatal("accepted", string(data)) + } + } + copy := strings.Repeat("x", MaxPresentationBytes+1) + data, _ := json.Marshal(Interaction{Clipboard: ©}) + if _, err := DecodeInteraction(data); err == nil { + t.Fatal("accepted oversized clipboard write") + } + form.Title = "Bad\x1btitle" + data, _ = json.Marshal(Interaction{Form: form, Next: "choice"}) + if _, err := DecodeInteraction(data); err == nil { + t.Fatal("accepted control in title") + } +} +func TestInteractionGrantsRemainExplicit(t *testing.T) { + for _, apis := range [][]string{{"commands"}, {"commands", "presentation"}, {"commands", "presentation", "interaction"}} { + if !ValidAPIs(apis, apis) { + t.Fatal("rejected", apis) + } + } + for _, apis := range [][]string{{"commands", "interaction"}, {"commands", "presentation", "unknown"}, {"commands", "interaction", "presentation"}} { + if ValidAPIs(apis, apis) { + t.Fatal("accepted", apis) + } + } + if ValidAPIs([]string{"commands", "presentation", "interaction"}, []string{"commands", "presentation"}) { + t.Fatal("missing grant accepted") + } +} diff --git a/internal/tentaclewire/presentation.go b/internal/tentaclewire/presentation.go new file mode 100644 index 0000000..438f865 --- /dev/null +++ b/internal/tentaclewire/presentation.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import ( + "bytes" + "encoding/json" + "unicode" + "unicode/utf8" +) + +const MaxPresentationBytes = 8192 // Per channel, measured in UTF-8 bytes. + +// Presentation is a command result, never an unsolicited announcement. Empty +// channels are omitted; neither channel supplies positions or routing targets. +type Presentation struct { + Speech string `json:"speech,omitempty"` + Braille string `json:"braille,omitempty"` + Uninterruptible bool `json:"uninterruptible,omitempty"` +} + +func DecodePresentation(data []byte) (Presentation, error) { + var value Presentation + if err := DecodeStrict(data, &value); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if json.Unmarshal(data, &fields) != nil || fields == nil || len(fields) == 0 { + return value, ErrProtocol + } + for _, field := range fields { + if bytes.Equal(field, []byte("null")) { + return value, ErrProtocol + } + } + if value.Speech == "" && value.Braille == "" { + return value, ErrProtocol + } + if value.Uninterruptible && value.Speech == "" { + return value, ErrProtocol + } + for _, text := range []string{value.Speech, value.Braille} { + if len(text) > MaxPresentationBytes { + return value, ErrLimit + } + if !utf8.ValidString(text) { + return value, ErrProtocol + } + for _, char := range text { + if unicode.IsControl(char) && char != '\n' && char != '\t' { + return value, ErrProtocol + } + } + } + return value, nil +} + +// The experimental optional API is additive to the command-only handshake. +func ValidAPIs(apis, capabilities []string) bool { + if len(apis) != len(capabilities) || len(apis) < 1 || len(apis) > 3 || apis[0] != "commands" { + return false + } + if len(apis) >= 2 && apis[1] != "presentation" || len(apis) == 3 && apis[2] != "interaction" { + return false + } + for index := range apis { + if apis[index] != capabilities[index] { + return false + } + } + return true +} diff --git a/internal/tentaclewire/presentation_test.go b/internal/tentaclewire/presentation_test.go new file mode 100644 index 0000000..5bd5fa9 --- /dev/null +++ b/internal/tentaclewire/presentation_test.go @@ -0,0 +1,37 @@ +package tentaclewire + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestPresentationContract(t *testing.T) { + for _, value := range []Presentation{ + {Speech: "Hello, é猫😀é"}, {Braille: "é猫😀é"}, + {Speech: "Speech text", Braille: "Display text"}, + {Speech: "Hello world!", Braille: "Hello world!", Uninterruptible: true}, + {Speech: strings.Repeat("é", MaxPresentationBytes/2)}, + } { + data, _ := json.Marshal(value) + decoded, err := DecodePresentation(data) + if err != nil || decoded != value { + t.Fatalf("round trip: %+v, %v", decoded, err) + } + } + for _, data := range []string{`null`, `[]`, `{}`, `{"speech":null}`, `{"speech":42}`, + `{"speech":""}`, `{"speech":"hello","voice":"default"}`, `{"speech":"one","speech":"two"}`, + `{"braille":"bad\u0000text"}`, `{"speech":"bad\u001btext"}`, + `{"braille":"hello","uninterruptible":true}`, `{"uninterruptible":true}`, + `{"speech":"hello","uninterruptible":null}`, `{"speech":"hello","uninterruptible":"true"}`, + `{"speech":"hello","uninterruptible":true,"uninterruptible":false}`} { + if _, err := DecodePresentation([]byte(data)); !errors.Is(err, ErrProtocol) { + t.Fatalf("accepted %s: %v", data, err) + } + } + data, _ := json.Marshal(Presentation{Braille: strings.Repeat("é", MaxPresentationBytes/2+1)}) + if _, err := DecodePresentation(data); !errors.Is(err, ErrLimit) { + t.Fatal("UTF-8 byte bound", err) + } +} diff --git a/internal/tentaclewire/wire.go b/internal/tentaclewire/wire.go new file mode 100644 index 0000000..7e2a186 --- /dev/null +++ b/internal/tentaclewire/wire.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package tentaclewire shares bounded framing between the host and Go SDK. +package tentaclewire + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "unicode/utf8" +) + +const MaxFrameBytes = 64 * 1024 + +var ErrProtocol = errors.New("invalid tentacle protocol message") +var ErrLimit = errors.New("tentacle resource limit exceeded") + +// ReadFrame bounds allocation even when the child never writes a newline. +func ReadFrame(reader *bufio.Reader) ([]byte, error) { + var frame []byte + for { + part, err := reader.ReadSlice('\n') + if len(frame)+len(part) > MaxFrameBytes+1 { + return nil, ErrLimit + } + frame = append(frame, part...) + if err == nil { + return frame[:len(frame)-1], nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) && len(frame) != 0 { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } +} + +// Reject duplicate keys and excessive nesting, including in command arguments +// and results. Different languages must not interpret the same frame differently. +func ValidateJSON(data []byte) error { + if !utf8.Valid(data) { + return ErrProtocol + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := validate_value(decoder, 0); err != nil { + return ErrProtocol + } + if _, err := decoder.Token(); err != io.EOF { + return ErrProtocol + } + return nil +} + +func validate_value(decoder *json.Decoder, depth int) error { + if depth > 32 { + return ErrLimit + } + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, composite := token.(json.Delim) + if !composite { + return nil + } + if delimiter != '{' && delimiter != '[' { + return ErrProtocol + } + keys := map[string]bool{} + for decoder.More() { + if delimiter == '{' { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok || keys[key] { + return ErrProtocol + } + keys[key] = true + } + if err := validate_value(decoder, depth+1); err != nil { + return err + } + } + _, err = decoder.Token() + return err +} + +func DecodeStrict(data []byte, target any) error { + if err := ValidateJSON(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return ErrProtocol + } + return nil +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..f9bda88 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,40 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package version + +import "strings" + +const Name = "Cthulhu" + +const Version = "2026.10.12" +const Channel = "master" + +var Build = Version + +func String() string { + return Name + " screen reader version " + Build + "-" + Channel +} + +func Details(atspiVersion, sessionType, sessionDesktop string) string { + details := String() + ", AT-SPI2 version: " + atspiVersion + session := strings.TrimSpace(sessionType + " " + sessionDesktop) + if session != "" { + details += ", Session: " + session + } + return details +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..1bb7feb --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,53 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package version + +import ( + "strings" + "testing" +) + +func TestStringIncludesBuild(t *testing.T) { + originalBuild := Build + Build = "test-build" + defer func() { Build = originalBuild }() + + if got, want := String(), "Cthulhu screen reader version test-build-master"; got != want { + t.Fatalf("String() = %q, want %q", got, want) + } +} + +func TestStringUsesCurrentVersionByDefault(t *testing.T) { + if Build != Version { + t.Fatalf("Build = %q, want %q", Build, Version) + } + if got, want := String(), "Cthulhu screen reader version "+Version+"-master"; got != want { + t.Fatalf("String() = %q, want %q", got, want) + } +} + +func TestDetailsIncludesATSPIAndSession(t *testing.T) { + got := Details("2.58.0", "x11", "GNOME") + want := "Cthulhu screen reader version " + Version + "-master, AT-SPI2 version: 2.58.0, Session: x11 GNOME" + if got != want { + t.Fatalf("Details() = %q, want %q", got, want) + } + if Details("2.58.0", " ", "") != strings.TrimSuffix(got, ", Session: x11 GNOME") { + t.Fatal("empty session information was included") + } +} diff --git a/internal/webscripts/registry.go b/internal/webscripts/registry.go new file mode 100644 index 0000000..b56ff46 --- /dev/null +++ b/internal/webscripts/registry.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package webscripts owns application-specific web exceptions. It consumes +// copied semantics, never native accessibles, and does not depend on the reader. +package webscripts + +import "time" + +type Script uint8 + +const ( + Default Script = iota + Steam +) + +// MatchProcess uses the provider's verified process identity, not a page title +// or the ambiguous AT-SPI application name "Chromium". +func MatchProcess(processName string) Script { + if processName == "steamwebhelper" { + return Steam + } + return Default +} + +// Notification contains only the selected root identity and bounded content. +type Notification struct { + BusName, RootPath, Text string + WindowPath string +} + +type NotificationCandidate struct { + Role, Live, ContainerLive string +} + +func NotificationEvent(eventType string, detail int) bool { + return eventType == "object:text-changed:insert" || + eventType == "object:children-changed:add" || + eventType == "object:state-changed:showing" && detail != 0 +} + +func HandlesNotification(processName, eventType string, detail int, message *Notification) bool { + return MatchProcess(processName) == Steam && NotificationEvent(eventType, detail) && + message != nil && message.BusName != "" && message.RootPath != "" +} + +// Registry is owned by the reader's event loop. The loop owns its timer and +// presents completed messages using the shared speech/braille/history services. +type Registry struct { + steam steamNotifications +} + +func (registry *Registry) Receive(processName, eventType string, detail int, message *Notification, now time.Time) ([]string, bool) { + if !HandlesNotification(processName, eventType, detail, message) { + return nil, false + } + return registry.steam.queue(*message, now), true +} + +func (registry *Registry) Flush(now time.Time) []string { return registry.steam.flush(now) } +func (registry *Registry) Deadline() time.Time { return registry.steam.deadline() } +func (registry *Registry) Reset() { registry.steam = steamNotifications{} } + +// Retire removes delayed content whose accessible root has been destroyed. +func (registry *Registry) Retire(busName, rootPath string) { + for key, pending := range registry.steam.pending { + if key.busName == busName && (key.rootPath == rootPath || pending.windowPath == rootPath) { + delete(registry.steam.pending, key) + } + } +} diff --git a/internal/webscripts/steam.go b/internal/webscripts/steam.go new file mode 100644 index 0000000..cdeb2b2 --- /dev/null +++ b/internal/webscripts/steam.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package webscripts + +import ( + "regexp" + "sort" + "strings" + "time" +) + +const steamNotificationDelay = 500 * time.Millisecond + +var steamRelativeTime = regexp.MustCompile(`(?i)^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$`) + +// NotificationRoot mirrors Python's Steam root selection: notification/alert +// ancestors take precedence over inherited live-region attributes on a leaf. +// Candidates are ordered from the event source towards its application root. +func NotificationRoot(candidates []NotificationCandidate) int { + for index, candidate := range candidates { + if candidate.Role == "notification" || candidate.Role == "alert" { + return index + } + } + for index, candidate := range candidates { + if candidate.Live == "assertive" || candidate.Live == "polite" || + candidate.ContainerLive == "assertive" || candidate.ContainerLive == "polite" { + return index + } + } + return -1 +} + +type notificationKey struct{ busName, rootPath string } +type pendingNotification struct { + text string + windowPath string + timestampOnly bool + deadline time.Time +} +type completedNotification struct { + text string + at time.Time +} +type steamNotifications struct { + pending map[notificationKey]pendingNotification + last map[string]completedNotification +} + +func normalize(text string) string { + return strings.Join(strings.Fields(strings.ReplaceAll(text, "\ufffc", "")), " ") +} + +func contains(text, other string) bool { + text, other = strings.ToLower(text), strings.ToLower(other) + if len(other) < 4 { + return text == other + } + return strings.Contains(text, other) +} + +func appendTimestamp(text, timestamp string) string { + if strings.ContainsAny(text[len(text)-1:], ".!?") { + return text + " " + timestamp + } + return text + ". " + timestamp +} + +func (steam *steamNotifications) queue(message Notification, now time.Time) []string { + text := normalize(message.Text) + if text == "" { + return nil + } + if steam.pending == nil { + steam.pending = make(map[notificationKey]pendingNotification) + } + key := notificationKey{message.BusName, message.RootPath} + pending, exists := steam.pending[key] + timestamp := steamRelativeTime.MatchString(text) + var messages []string + if exists { + switch { + case text == pending.text: + return nil + case timestamp: + if pending.timestampOnly { + pending.text = text + } else { + pending.text = appendTimestamp(pending.text, text) + } + case pending.timestampOnly: + pending.text = appendTimestamp(text, pending.text) + pending.timestampOnly = false + case contains(text, pending.text): + pending.text = text + case contains(pending.text, text): + return nil + default: + pending.text = normalize(pending.text + " " + text) + } + } else { + // Bound outstanding roots without merging independent toasts. Finish + // the oldest root if a provider emits an excessive burst. + if len(steam.pending) >= 32 { + oldest := steam.orderedKeys()[0] + messages = steam.complete(oldest, now) + } + pending = pendingNotification{text: text, timestampOnly: timestamp} + } + // Bound accumulated fragments as well as each native capture. + characters := []rune(pending.text) + if len(characters) > 4096 { + pending.text = string(characters[:4096]) + } + pending.deadline = now.Add(steamNotificationDelay) + pending.windowPath = message.WindowPath + steam.pending[key] = pending + return messages +} + +func (steam *steamNotifications) orderedKeys() []notificationKey { + keys := make([]notificationKey, 0, len(steam.pending)) + for key := range steam.pending { + keys = append(keys, key) + } + sort.Slice(keys, func(first, second int) bool { + left, right := keys[first], keys[second] + if steam.pending[left].deadline.Equal(steam.pending[right].deadline) { + if left.busName == right.busName { + return left.rootPath < right.rootPath + } + return left.busName < right.busName + } + return steam.pending[left].deadline.Before(steam.pending[right].deadline) + }) + return keys +} + +func (steam *steamNotifications) complete(key notificationKey, now time.Time) []string { + pending := steam.pending[key] + delete(steam.pending, key) + if pending.timestampOnly { + return nil + } + last := steam.last[key.busName] + if last.text == pending.text && now.Sub(last.at) < time.Second { + return nil + } + if steam.last == nil { + steam.last = make(map[string]completedNotification) + } + for busName, completed := range steam.last { + if now.Sub(completed.at) >= time.Second { + delete(steam.last, busName) + } + } + if len(steam.last) >= 64 { + clear(steam.last) + } + steam.last[key.busName] = completedNotification{text: pending.text, at: now} + return []string{"Steam: " + pending.text} +} + +func (steam *steamNotifications) flush(now time.Time) []string { + var messages []string + for _, key := range steam.orderedKeys() { + if !steam.pending[key].deadline.After(now) { + messages = append(messages, steam.complete(key, now)...) + } + } + return messages +} + +func (steam *steamNotifications) deadline() time.Time { + keys := steam.orderedKeys() + if len(keys) == 0 { + return time.Time{} + } + return steam.pending[keys[0]].deadline +} diff --git a/internal/webscripts/steam_test.go b/internal/webscripts/steam_test.go new file mode 100644 index 0000000..6de1016 --- /dev/null +++ b/internal/webscripts/steam_test.go @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package webscripts + +import ( + "reflect" + "testing" + "time" +) + +func TestSteamNotificationMatchingAndRoot(t *testing.T) { + message := &Notification{BusName: ":1.42", RootPath: "/toast", Text: "Username is now online"} + for _, process := range []string{"Chromium", "chromium", "brave", "", "steamwebhelper.exe"} { + if HandlesNotification(process, "object:text-changed:insert", 0, message) { + t.Fatalf("enabled Steam for %q", process) + } + } + for _, event := range []struct { + typeName string + detail int + want bool + }{ + {"object:text-changed:insert", 0, true}, + {"object:children-changed:add", 0, true}, + {"object:state-changed:showing", 1, true}, + {"object:state-changed:showing", 0, false}, + {"object:state-changed:focused", 1, false}, + } { + if got := HandlesNotification("steamwebhelper", event.typeName, event.detail, message); got != event.want { + t.Fatalf("%s/%d: %t", event.typeName, event.detail, got) + } + } + candidates := []NotificationCandidate{{ContainerLive: "assertive"}, {Role: "alert"}} + if index := NotificationRoot(candidates); index != 1 { + t.Fatalf("selected live leaf instead of alert ancestor: %d", index) + } + if index := NotificationRoot([]NotificationCandidate{{Live: "off"}, {Live: "polite"}}); index != 1 { + t.Fatalf("live root = %d", index) + } + if index := NotificationRoot([]NotificationCandidate{{Live: "off"}}); index != -1 { + t.Fatalf("non-live root = %d", index) + } +} + +func TestSteamNotificationCoalescing(t *testing.T) { + for _, test := range []struct { + name string + fragments []string + want []string + }{ + {"fragments", []string{" Username ", "Playing: Example Game"}, []string{"Steam: Username Playing: Example Game"}}, + {"timestamp after", []string{"Username is now online", "2 minutes ago"}, []string{"Steam: Username is now online. 2 minutes ago"}}, + {"timestamp before", []string{"2 minutes ago", "Username is now online"}, []string{"Steam: Username is now online. 2 minutes ago"}}, + {"complete replaces fragment", []string{"Username", "Username is now online", "Username"}, []string{"Steam: Username is now online"}}, + {"repeated complete", []string{"Username is now online", "Username is now online"}, []string{"Steam: Username is now online"}}, + {"timestamp only", []string{"just now"}, nil}, + {"empty", []string{"", " \ufffc "}, nil}, + } { + t.Run(test.name, func(t *testing.T) { + var registry Registry + now := time.Unix(100, 0) + for _, text := range test.fragments { + messages, handled := registry.Receive("steamwebhelper", "object:text-changed:insert", 0, + &Notification{BusName: ":1.42", RootPath: "/toast", Text: text}, now) + if !handled || len(messages) != 0 { + t.Fatalf("fragment presented early: %q, handled %t", messages, handled) + } + } + if got := registry.Flush(now.Add(499 * time.Millisecond)); len(got) != 0 { + t.Fatalf("presented before deadline: %q", got) + } + if got := registry.Flush(now.Add(500 * time.Millisecond)); !reflect.DeepEqual(got, test.want) { + t.Fatalf("messages = %q, want %q", got, test.want) + } + if !registry.Deadline().IsZero() { + t.Fatal("completed timer still armed") + } + }) + } +} + +func TestSteamNotificationDeduplicationAndOwnership(t *testing.T) { + var registry Registry + now := time.Unix(100, 0) + queue := func(bus, root, text string, at time.Time) { + registry.Receive("steamwebhelper", "object:text-changed:insert", 0, + &Notification{BusName: bus, RootPath: root, Text: text, WindowPath: "/window"}, at) + } + queue(":1.42", "/first", "Username is now online", now) + if got := registry.Flush(now.Add(500 * time.Millisecond)); len(got) != 1 { + t.Fatalf("initial message: %q", got) + } + queue(":1.42", "/first", "Username is now online", now.Add(600*time.Millisecond)) + if got := registry.Flush(now.Add(1100 * time.Millisecond)); len(got) != 0 { + t.Fatalf("duplicate within one second: %q", got) + } + queue(":1.42", "/first", "Username is now online", now.Add(time.Second)) + if got := registry.Flush(now.Add(1500 * time.Millisecond)); len(got) != 1 { + t.Fatalf("new message after one second: %q", got) + } + queue(":1.42", "/first", "Username", now.Add(2*time.Second)) + queue(":1.42", "/second", "Playing: Example Game", now.Add(2*time.Second)) + queue(":1.43", "/first", "2 minutes ago", now.Add(2*time.Second)) + if got := registry.Flush(now.Add(2500 * time.Millisecond)); !reflect.DeepEqual(got, []string{"Steam: Username", "Steam: Playing: Example Game"}) { + t.Fatalf("independent roots combined: %q", got) + } + queue(":1.42", "/first", "retired message", now.Add(3*time.Second)) + registry.Retire(":1.43", "/window") + if registry.Deadline().IsZero() { + t.Fatal("other bus retired pending toast") + } + registry.Retire(":1.42", "/window") + if got := registry.Flush(now.Add(4 * time.Second)); len(got) != 0 { + t.Fatalf("destroyed window presented: %q", got) + } + queue(":1.42", "/first", "locked message", now) + registry.Reset() + if got := registry.Flush(now.Add(time.Second)); len(got) != 0 { + t.Fatalf("reset presented pending content: %q", got) + } +} diff --git a/internal/wine/manager.go b/internal/wine/manager.go new file mode 100644 index 0000000..52499ad --- /dev/null +++ b/internal/wine/manager.go @@ -0,0 +1,326 @@ +// Package wine supervises the optional installed Cthulhu Wine accessibility +// helper. Native Wine objects stay in that process; Go receives D-Bus messages. +package wine + +import ( + "context" + "errors" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const managedKey = "CTHULHU_WINE_ACCESS_MANAGED" + +type prefix struct { + path, loader string + environment map[string]string +} + +type process struct { + cmd *exec.Cmd + done chan struct{} + started time.Time + seen time.Time + failures int + next time.Time +} + +type Manager struct { + logger *slog.Logger + helper string + procRoot string + cancel context.CancelFunc + done chan struct{} +} + +func New(logger *slog.Logger) *Manager { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &Manager{logger: logger, helper: helperPath(), procRoot: "/proc"} +} + +func helperPath() string { + if path := os.Getenv("CTHULHU_WINE_ACCESS_HELPER"); executable(path) { + return path + } + if path := filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"); executable(path) { + return path + } + if path := "/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"; executable(path) { + return path + } + if path, err := exec.LookPath("cthulhu-wine-access.exe"); err == nil { + return path + } + return "" +} + +func executable(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0 +} + +func (manager *Manager) Start(ctx context.Context) error { + if manager.helper == "" { + manager.logger.Info("Wine accessibility helper unavailable") + return nil + } + if manager.cancel != nil { + return errors.New("Wine accessibility manager already started") + } + workerCtx, cancel := context.WithCancel(ctx) + manager.cancel = cancel + manager.done = make(chan struct{}) + go manager.run(workerCtx) + return nil +} + +func (manager *Manager) Stop(ctx context.Context) error { + if manager.cancel == nil { + return nil + } + manager.cancel() + select { + case <-manager.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (manager *Manager) run(ctx context.Context) { + defer close(manager.done) + active := make(map[string]*process) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + manager.poll(active) + for { + select { + case <-ctx.Done(): + for _, child := range active { + stop(child) + } + return + case <-ticker.C: + manager.poll(active) + } + } +} + +func (manager *Manager) poll(active map[string]*process) { + now := time.Now() + discovered := discover(manager.procRoot, uint32(os.Getuid())) + for path, source := range discovered { + child := active[path] + if child == nil { + child = &process{} + active[path] = child + } + child.seen = now + if child.cmd != nil { + select { + case <-child.done: + if now.Sub(child.started) < 10*time.Second { + child.failures++ + } else { + child.failures = 0 + } + child.next = now.Add(backoff(child.failures)) + child.cmd = nil + default: + continue + } + } + if now.Before(child.next) { + continue + } + if err := manager.launch(child, source, now); err != nil { + child.failures++ + child.next = now.Add(backoff(child.failures)) + manager.logger.Warn("start Wine accessibility helper", "error", err) + } + } + for path, child := range active { + if _, found := discovered[path]; !found && now.Sub(child.seen) >= 10*time.Second { + stop(child) + delete(active, path) + } + } +} + +func backoff(failures int) time.Duration { + if failures > 6 { + failures = 6 + } + return time.Duration(1< \"$CTHULHU_TEST_WINE_OUTPUT\"\nexec sleep 30\n" + if err := os.WriteFile(helper, []byte(script), 0700); err != nil { + t.Fatal(err) + } + manager := &Manager{logger: slog.New(slog.DiscardHandler), helper: helper, procRoot: procRoot} + if err := manager.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer manager.Stop(context.Background()) + deadline := time.Now().Add(time.Second) + var contents []byte + for time.Now().Before(deadline) { + contents, _ = os.ReadFile(output) + if len(contents) != 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if string(contents) != prefixPath+"\n1\n" { + t.Fatalf("helper environment = %q", contents) + } + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := manager.Stop(stopCtx); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverSteamCompatPrefix(t *testing.T) { + root := t.TempDir() + loader := filepath.Join(root, "wine") + if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil { + t.Fatal(err) + } + proc := filepath.Join(root, "200") + if err := os.Mkdir(proc, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proc, "environ"), []byte(strings.Join([]string{ + "STEAM_COMPAT_DATA_PATH=" + root, "WINELOADER=" + loader, ""}, "\x00")), 0600); err != nil { + t.Fatal(err) + } + got := discover(root, uint32(os.Getuid())) + selected := got[filepath.Join(root, "pfx")] + if selected.loader != loader { + t.Fatalf("Steam loader = %q, want %q", selected.loader, loader) + } +} + +func TestApplicationProcessExcludesWineServices(t *testing.T) { + for _, program := range []string{"services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe"} { + if applicationProcess("C:\\windows\\system32\\" + strings.ToUpper(program) + "\x00") { + t.Errorf("Wine service %q counted as an application", program) + } + } + for _, test := range []struct { + commandLine string + want bool + }{ + {"", false}, + {"C:\\windows\\explorer.exe\x00/desktop\x00", false}, + {"C:\\windows\\explorer.exe\x00/DESKTOP=Default,1024x768\x00", false}, + {"C:\\windows\\explorer.exe\x00C:\\documents\x00", true}, + {"C:\\Program Files\\Application\\app.exe\x00/desktop\x00", true}, + {"C:\\windows\\system32\\notepad.exe\x00", true}, + {"/usr/bin/wine\x00application.exe\x00", true}, + } { + if got := applicationProcess(test.commandLine); got != test.want { + t.Errorf("applicationProcess(%q) = %v, want %v", test.commandLine, got, test.want) + } + } +} + +func TestManagerStopsHelperWhenOnlyWineServicesRemain(t *testing.T) { + root := t.TempDir() + proc := filepath.Join(root, "101") + if err := os.Mkdir(proc, 0700); err != nil { + t.Fatal(err) + } + loader := filepath.Join(root, "wine") + helper := filepath.Join(root, "helper") + for _, path := range []string{loader, helper} { + if err := os.WriteFile(path, []byte("#!/bin/sh\nexec sleep 30\n"), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil { + t.Fatal(err) + } + prefixPath := filepath.Join(root, "prefix") + if err := os.WriteFile(filepath.Join(proc, "environ"), []byte("WINEPREFIX="+prefixPath+"\x00"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil { + t.Fatal(err) + } + manager := &Manager{logger: slog.New(slog.DiscardHandler), helper: helper, procRoot: root} + active := make(map[string]*process) + manager.poll(active) + child := active[prefixPath] + if child == nil || child.cmd == nil { + t.Fatal("application did not start a helper") + } + defer stop(child) + if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("C:\\windows\\system32\\winedevice.exe\x00"), 0600); err != nil { + t.Fatal(err) + } + manager.poll(active) + if child.cmd == nil { + t.Fatal("helper stopped before the idle grace period") + } + child.seen = time.Now().Add(-11 * time.Second) + manager.poll(active) + if len(active) != 0 || child.cmd != nil { + t.Fatal("Wine service kept the helper alive after the idle grace period") + } + manager.poll(active) + if len(active) != 0 { + t.Fatal("Wine service restarted the helper") + } +} diff --git a/internal/wine/review_client.go b/internal/wine/review_client.go new file mode 100644 index 0000000..53bc2d1 --- /dev/null +++ b/internal/wine/review_client.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package wine + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/atspi" + "github.com/godbus/dbus/v5" + "time" +) + +func (r *ReviewRegistry) Capture(parent context.Context, id atspi.ObjectID) (*atspi.AccessibleTree, error) { + if !IsReviewID(id) { + return nil, ErrReviewUnavailable + } + ctx, cancel := context.WithTimeout(parent, 500*time.Millisecond) + defer cancel() + select { + case r.captureGate <- struct{}{}: + defer func() { <-r.captureGate }() + case <-ctx.Done(): + return nil, ctx.Err() + } + for attempt := 0; attempt < 2; attempt++ { + r.mutex.Lock() + p := r.providers[id.BusName] + if p == nil || p.ctx.Err() != nil || p.notice.ID() != id { + r.mutex.Unlock() + return nil, ErrReviewUnavailable + } + notice, lifetime, connection, call := p.notice, p.ctx, r.service, r.captureCall + r.mutex.Unlock() + if connection == nil && call == nil { + return nil, ErrReviewUnavailable + } + callCtx, callCancel := context.WithCancel(ctx) + stop := context.AfterFunc(lifetime, callCancel) + var fields map[string]dbus.Variant + var err error + if call != nil { + fields, err = call(callCtx, id.BusName, notice.Path, notice) + } else { + reply := connection.Object(id.BusName, notice.Path).CallWithContext(callCtx, ReviewInterface+".CaptureWindow", 0, notice.Window, notice.Focus, notice.Revision) + err = reply.Err + if err == nil { + // Store rebuilds variants from their decoded Go values and loses + // tuple signatures. Validate the original owned reply body. + if len(reply.Body) != 1 { + err = ErrReviewInvalid + } else { + var ok bool + fields, ok = reply.Body[0].(map[string]dbus.Variant) + if !ok { + err = ErrReviewInvalid + } + } + } + } + stop() + callCancel() + if err != nil { + return nil, err + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + snapshot, err := DecodeReviewSnapshot(fields) + if errors.Is(err, ErrReviewStale) && attempt == 0 { + continue + } + if err != nil { + return nil, err + } + if snapshot.Instance != notice.Instance || snapshot.Window != notice.Window || snapshot.Focus != notice.Focus || snapshot.Revision != notice.Revision || snapshot.Bounds != notice.Bounds { + return nil, ErrReviewStale + } + r.mutex.Lock() + if r.providers[id.BusName] != p || p.notice != notice || lifetime.Err() != nil { + r.mutex.Unlock() + return nil, ErrReviewStale + } + tree, _, err := ApplyReviewSnapshot(p.trees[notice.Window], snapshot, id.BusName) + if err == nil { + p.trees[notice.Window] = tree + } + r.mutex.Unlock() + return tree, err + } + return nil, ErrReviewStale +} diff --git a/internal/wine/review_protocol.go b/internal/wine/review_protocol.go new file mode 100644 index 0000000..a026f1e --- /dev/null +++ b/internal/wine/review_protocol.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package wine + +import ( + "encoding/hex" + "errors" + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "github.com/godbus/dbus/v5" +) + +const ReviewProtocolVersion uint32 = 1 +const ReviewObjectPath dbus.ObjectPath = "/org/stormux/Cthulhu1/WineReview" +const ReviewInterface = "org.stormux.Cthulhu1.WineReview" + +var ( + ErrReviewUnavailable = errors.New("Wine review unavailable") + ErrReviewStale = errors.New("Wine review source changed") + ErrReviewBusy = errors.New("Wine review capture busy") + ErrReviewLimited = errors.New("Wine review capture exceeds limits") + ErrReviewInvalid = errors.New("invalid Wine review snapshot") +) + +// Notices are copied by the registry. Sender and Path are supplied by actual +// registration, never by native payloads. The event loop owns their eligibility. +type ReviewFocusNotice struct { + Sender string + Path dbus.ObjectPath + Instance, Window, Focus string + Revision uint64 + Title, Role string + Protected bool + Bounds atspi.Rectangle +} +type ReviewRange struct { + Start, End, UTF16Start, UTF16End uint64 + Text string + Bounds atspi.Rectangle +} +type ReviewNode struct { + ID, Parent, Role, Name string + Protected, Visible, TextKnown, CaretKnown bool + Caret, CaretUTF16 uint64 + Bounds atspi.Rectangle + Ranges []ReviewRange +} +type ReviewSnapshot struct { + Version uint32 + Instance, Window, Focus string + Revision uint64 + Status string + Bounds atspi.Rectangle + Nodes []ReviewNode +} + +func ReviewID(sender, instance, window, node string) atspi.ObjectID { + encode := func(value string) string { return hex.EncodeToString([]byte(value)) } + return atspi.NewObjectID(sender, "/wine/"+encode(instance)+"/"+encode(window)+"/"+encode(node)) +} +func IsReviewID(id atspi.ObjectID) bool { + if !id.Valid() || !strings.HasPrefix(id.BusName, ":") { + return false + } + parts := strings.Split(id.Path, "/") + if len(parts) != 5 || parts[0] != "" || parts[1] != "wine" { + return false + } + for _, part := range parts[2:] { + if part == "" || len(part) > 512 { + return false + } + if _, err := hex.DecodeString(part); err != nil { + return false + } + } + return true +} +func (notice ReviewFocusNotice) ID() atspi.ObjectID { + return ReviewID(notice.Sender, notice.Instance, notice.Window, notice.Focus) +} diff --git a/internal/wine/review_registry.go b/internal/wine/review_registry.go new file mode 100644 index 0000000..9801abd --- /dev/null +++ b/internal/wine/review_registry.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package wine + +import ( + "context" + "errors" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/sessionbus" + "github.com/godbus/dbus/v5" + "log/slog" + "sort" + "strings" + "sync" +) + +type reviewProvider struct { + path dbus.ObjectPath + notice ReviewFocusNotice + ctx context.Context + cancel context.CancelFunc + trees map[string]*atspi.AccessibleTree +} +type ReviewRegistry struct { + logger *slog.Logger + mutex sync.Mutex + ctx context.Context + cancel context.CancelFunc + monitor, service *dbus.Conn + signals chan *dbus.Signal + done chan struct{} + changes chan struct{} + providers map[string]*reviewProvider + captureGate chan struct{} + captureCall func(context.Context, string, dbus.ObjectPath, ReviewFocusNotice) (map[string]dbus.Variant, error) +} + +func NewReviewRegistry(logger *slog.Logger) *ReviewRegistry { + return &ReviewRegistry{logger: logger, changes: make(chan struct{}, 1), providers: make(map[string]*reviewProvider), captureGate: make(chan struct{}, 1)} +} +func (r *ReviewRegistry) notify() { + select { + case r.changes <- struct{}{}: + default: + } +} +func (r *ReviewRegistry) Changes() <-chan struct{} { return r.changes } +func (r *ReviewRegistry) Start(ctx context.Context) error { + if ctx == nil { + return errors.New("Wine review requires a context") + } + conn, err := sessionbus.Connect(r.logger, "wine-review") + if err != nil { + return err + } + options := []dbus.MatchOption{dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged")} + signals := make(chan *dbus.Signal, 64) + conn.Signal(signals) + if err = conn.AddMatchSignal(options...); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + r.mutex.Lock() + if r.ctx != nil { + r.mutex.Unlock() + conn.RemoveSignal(signals) + conn.Close() + return errors.New("Wine review already started") + } + r.ctx, r.cancel = context.WithCancel(ctx) + r.monitor = conn + r.signals = signals + r.done = make(chan struct{}) + lifetime, done := r.ctx, r.done + r.mutex.Unlock() + go func() { + defer close(done) + for { + select { + case <-lifetime.Done(): + return + case <-conn.Context().Done(): + r.retireAll() + return + case signal := <-signals: + if signal != nil && len(signal.Body) == 3 { + name, ok := signal.Body[0].(string) + old, _ := signal.Body[1].(string) + next, _ := signal.Body[2].(string) + if ok && name == old && next == "" { + r.disappeared(name) + } + } + } + } + }() + return nil +} + +// The service connection is borrowed because native capture accepts only the +// current owner of Cthulhu's well-known name. Registry never closes it. +func (r *ReviewRegistry) BindWineReviewConnection(conn *dbus.Conn) { + r.mutex.Lock() + r.service = conn + if conn == nil { + for key, p := range r.providers { + p.cancel() + delete(r.providers, key) + } + } + r.notify() + r.mutex.Unlock() +} +func (r *ReviewRegistry) Stop(ctx context.Context) error { + r.mutex.Lock() + if r.cancel != nil { + r.cancel() + } + conn, signals, done := r.monitor, r.signals, r.done + for key, p := range r.providers { + p.cancel() + delete(r.providers, key) + } + r.ctx = nil + r.cancel = nil + r.monitor = nil + r.service = nil + r.signals = nil + r.done = nil + r.notify() + r.mutex.Unlock() + if conn != nil { + conn.RemoveSignal(signals) + conn.RemoveMatchSignal(dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged")) + conn.Close() + } + if done != nil { + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} +func (r *ReviewRegistry) RegisterProvider(sender string, path dbus.ObjectPath, version uint32) bool { + if !strings.HasPrefix(sender, ":") || !dbus.ObjectPath("/"+strings.ReplaceAll(sender[1:], ".", "/")).IsValid() || path != ReviewObjectPath || version != ReviewProtocolVersion { + return false + } + r.mutex.Lock() + defer r.mutex.Unlock() + if r.ctx == nil || r.ctx.Err() != nil { + return false + } + if old := r.providers[sender]; old != nil { + old.cancel() + } + ctx, cancel := context.WithCancel(r.ctx) + r.providers[sender] = &reviewProvider{path: path, ctx: ctx, cancel: cancel, trees: make(map[string]*atspi.AccessibleTree)} + r.notify() + return true +} +func (r *ReviewRegistry) PresentFocus(sender string, notice ReviewFocusNotice) bool { + if !reviewString(notice.Instance, 256, false) || !reviewString(notice.Window, 256, false) || !reviewString(notice.Focus, 256, false) || notice.Revision == 0 || !reviewString(notice.Title, 4096, true) || !reviewString(notice.Role, 4096, false) || !notice.Bounds.Valid() { + return false + } + r.mutex.Lock() + defer r.mutex.Unlock() + p := r.providers[sender] + if p == nil || p.ctx.Err() != nil { + return false + } + notice.Sender = sender + notice.Path = p.path + if p.notice == notice { + return true + } + if p.notice.Instance == notice.Instance && p.notice.Revision > notice.Revision { + return false + } + if p.notice.Instance != notice.Instance || p.notice.Window != notice.Window { + // Only the active native window can be captured. Retiring older + // windows bounds retained text and preserves the current tree's identity. + p.trees = make(map[string]*atspi.AccessibleTree) + } + p.cancel() + p.ctx, p.cancel = context.WithCancel(r.ctx) + p.notice = notice + r.notify() + return true +} +func (r *ReviewRegistry) Focuses() []ReviewFocusNotice { + r.mutex.Lock() + defer r.mutex.Unlock() + var notices []ReviewFocusNotice + for _, p := range r.providers { + if p.notice.Focus != "" && p.ctx.Err() == nil { + notices = append(notices, p.notice) + } + } + sort.Slice(notices, func(i, j int) bool { return notices[i].Sender < notices[j].Sender }) + return notices +} +func (r *ReviewRegistry) Current(id atspi.ObjectID) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + p := r.providers[id.BusName] + return IsReviewID(id) && p != nil && p.ctx.Err() == nil && p.notice.ID() == id +} +func (r *ReviewRegistry) disappeared(sender string) { + r.mutex.Lock() + defer r.mutex.Unlock() + if p := r.providers[sender]; p != nil { + p.cancel() + delete(r.providers, sender) + r.notify() + } +} +func (r *ReviewRegistry) retireAll() { + r.mutex.Lock() + defer r.mutex.Unlock() + for sender, p := range r.providers { + p.cancel() + delete(r.providers, sender) + } + r.notify() +} diff --git a/internal/wine/review_registry_test.go b/internal/wine/review_registry_test.go new file mode 100644 index 0000000..6ead358 --- /dev/null +++ b/internal/wine/review_registry_test.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package wine + +import ( + "context" + "errors" + "fmt" + "git.stormux.org/storm/cthulhu/internal/atspi" + "github.com/godbus/dbus/v5" + "testing" + "time" +) + +func TestReviewRegistryRetiresInactiveWindowTrees(t *testing.T) { + r := readyReviewRegistry(t) + n := registerNotice(t, r, ":1.2") + r.captureCall = func(_ context.Context, _ string, _ dbus.ObjectPath, notice ReviewFocusNotice) (map[string]dbus.Variant, error) { + f := reviewWire() + f["window"] = dbus.MakeVariant(notice.Window) + f["revision"] = dbus.MakeVariant(notice.Revision) + nodes := f["nodes"].Value().([]map[string]dbus.Variant) + nodes[0]["id"] = dbus.MakeVariant(notice.Window) + for _, node := range nodes[1:] { + if node["parent"].Value() == "window" { + node["parent"] = dbus.MakeVariant(notice.Window) + } + } + return f, nil + } + for i := 1; i <= 32; i++ { + n.Window = fmt.Sprintf("window%d", i) + n.Revision = uint64(i) + if !r.PresentFocus(n.Sender, n) { + t.Fatal("focus rejected") + } + one, err := r.Capture(context.Background(), n.ID()) + if err != nil { + t.Fatal(err) + } + two, err := r.Capture(context.Background(), n.ID()) + if err != nil || one != two { + t.Fatal("active window tree is not stable") + } + if len(r.providers[n.Sender].trees) != 1 { + t.Fatalf("retained %d inactive trees", len(r.providers[n.Sender].trees)-1) + } + } +} + +func registryNotice(sender string) ReviewFocusNotice { + return ReviewFocusNotice{Sender: sender, Path: ReviewObjectPath, Instance: "helper-instance", Window: "window", Focus: "input", Revision: 1, Title: "same title", Role: "window", Bounds: atspi.Rectangle{X: 10, Y: 20, Width: 400, Height: 300}} +} +func readyReviewRegistry(t *testing.T) *ReviewRegistry { + t.Helper() + registry := NewReviewRegistry(nil) + // Unit tests substitute transport only; registration still requires lifecycle. + registry.ctx, registry.cancel = context.WithCancel(context.Background()) + t.Cleanup(func() { registry.Stop(context.Background()) }) + return registry +} +func registerNotice(t *testing.T, r *ReviewRegistry, sender string) ReviewFocusNotice { + t.Helper() + notice := registryNotice(sender) + if !r.RegisterProvider(sender, ReviewObjectPath, 1) || !r.PresentFocus(sender, notice) { + t.Fatal("registration rejected") + } + return notice +} +func TestReviewRegistryTwoProviders(t *testing.T) { + r := readyReviewRegistry(t) + one := registerNotice(t, r, ":1.2") + two := registerNotice(t, r, ":1.3") + if one.ID() == two.ID() || len(r.Focuses()) != 2 || !r.Current(one.ID()) || !r.Current(two.ID()) { + t.Fatal("providers collided") + } + if len(r.changes) != 1 { + t.Fatal("notifications are not coalesced") + } + if !r.RegisterProvider(one.Sender, ReviewObjectPath, 1) || r.Current(one.ID()) || len(r.Focuses()) != 1 { + t.Fatal("registration retained old lifetime") + } +} +func TestReviewRegistrySenderAssociation(t *testing.T) { + r := readyReviewRegistry(t) + n := registryNotice(":1.2") + if r.PresentFocus(n.Sender, n) || r.RegisterProvider("well.known", ReviewObjectPath, 1) || r.RegisterProvider(n.Sender, ReviewObjectPath, 2) || r.RegisterProvider(n.Sender, "/wrong", 1) { + t.Fatal("invalid provider accepted") + } + registerNotice(t, r, n.Sender) + n.Sender = ":1.99" + n.Path = "/forged" + if !r.PresentFocus(":1.2", n) { + t.Fatal("actual sender rejected") + } + got := r.Focuses()[0] + if got.Sender != ":1.2" || got.Path != ReviewObjectPath { + t.Fatal("payload forged destination") + } + n.Revision = 0 + if r.PresentFocus(":1.2", n) { + t.Fatal("invalid focus accepted") + } +} +func TestReviewRegistryProviderDisappears(t *testing.T) { + r := readyReviewRegistry(t) + n := registerNotice(t, r, ":1.2") + r.disappeared(n.Sender) + if r.Current(n.ID()) || len(r.Focuses()) != 0 { + t.Fatal("dead provider retained") + } + if _, err := r.Capture(context.Background(), n.ID()); !errors.Is(err, ErrReviewUnavailable) { + t.Fatal(err) + } +} +func TestReviewCaptureDeadlineAndStaleReply(t *testing.T) { + r := readyReviewRegistry(t) + n := registerNotice(t, r, ":1.2") + calls := 0 + r.captureCall = func(ctx context.Context, sender string, path dbus.ObjectPath, notice ReviewFocusNotice) (map[string]dbus.Variant, error) { + calls++ + fields := reviewWire() + if calls == 1 { + fields["status"] = dbus.MakeVariant("stale") + fields["nodes"] = dbus.MakeVariant([]map[string]dbus.Variant{}) + } + return fields, nil + } + tree, err := r.Capture(context.Background(), n.ID()) + if err != nil || tree == nil || calls != 2 { + t.Fatalf("stale retry: %v calls=%d", err, calls) + } + again, err := r.Capture(context.Background(), n.ID()) + if err != nil || again != tree { + t.Fatal("capture replaced live tree") + } + r.captureCall = func(ctx context.Context, _ string, _ dbus.ObjectPath, _ ReviewFocusNotice) (map[string]dbus.Variant, error) { + <-ctx.Done() + return nil, ctx.Err() + } + started := time.Now() + if _, err = r.Capture(context.Background(), n.ID()); err == nil || time.Since(started) > 750*time.Millisecond { + t.Fatalf("deadline: %v", err) + } + r.captureCall = func(context.Context, string, dbus.ObjectPath, ReviewFocusNotice) (map[string]dbus.Variant, error) { + f := reviewWire() + f["revision"] = dbus.MakeVariant(uint64(2)) + return f, nil + } + if _, err = r.Capture(context.Background(), n.ID()); !errors.Is(err, ErrReviewStale) { + t.Fatalf("wrong source: %v", err) + } +} +func TestReviewCaptureStopCancels(t *testing.T) { + r := readyReviewRegistry(t) + n := registerNotice(t, r, ":1.2") + entered := make(chan struct{}) + done := make(chan error, 1) + r.captureCall = func(ctx context.Context, _ string, _ dbus.ObjectPath, _ ReviewFocusNotice) (map[string]dbus.Variant, error) { + close(entered) + <-ctx.Done() + return nil, ctx.Err() + } + go func() { _, err := r.Capture(context.Background(), n.ID()); done <- err }() + <-entered + r.Stop(context.Background()) + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled capture accepted") + } + case <-time.After(time.Second): + t.Fatal("stop blocked") + } +} diff --git a/internal/wine/review_snapshot.go b/internal/wine/review_snapshot.go new file mode 100644 index 0000000..e62fbca --- /dev/null +++ b/internal/wine/review_snapshot.go @@ -0,0 +1,331 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package wine + +import ( + "encoding/binary" + "fmt" + "reflect" + "strings" + "unicode/utf16" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "github.com/godbus/dbus/v5" +) + +const maxReviewSourceUnits uint64 = 1000000 + +func reviewString(value string, limit int, empty bool) bool { + return (empty || value != "") && len(value) <= limit && utf8.ValidString(value) && !strings.ContainsRune(value, 0) +} +func reviewField(fields map[string]dbus.Variant, key, signature string, destination any) error { + value, found := fields[key] + if !found || value.Signature().String() != signature { + return fmt.Errorf("%w: field %s signature %s (expected %s)", ErrReviewInvalid, key, value.Signature(), signature) + } + if !copyReviewValue(reflect.ValueOf(destination).Elem(), reflect.ValueOf(value.Value())) { + return fmt.Errorf("%w: field %s", ErrReviewInvalid, key) + } + return nil +} + +// D-Bus tuples arrive as []interface{}. Accept that representation and typed +// producer records, but never coerce a signed integer or a different wire type. +func copyReviewValue(dst, src reflect.Value) bool { + for src.IsValid() && src.Kind() == reflect.Interface { + src = src.Elem() + } + if !src.IsValid() { + return false + } + switch dst.Kind() { + case reflect.Struct: + if src.Kind() != reflect.Struct && src.Type() != reflect.TypeOf([]any{}) { + return false + } + n := src.Len + if src.Kind() == reflect.Struct { + n = src.NumField + } + if n() != dst.NumField() { + return false + } + for i := 0; i < dst.NumField(); i++ { + var field reflect.Value + if src.Kind() == reflect.Struct { + field = src.Field(i) + } else { + field = src.Index(i) + } + if !copyReviewValue(dst.Field(i), field) { + return false + } + } + return true + case reflect.Slice: + if src.Kind() != reflect.Slice || src.Len() > 256 { + return false + } + result := reflect.MakeSlice(dst.Type(), src.Len(), src.Len()) + for i := 0; i < src.Len(); i++ { + if !copyReviewValue(result.Index(i), src.Index(i)) { + return false + } + } + dst.Set(result) + return true + default: + if src.Type() != dst.Type() { + return false + } + dst.Set(src) + return true + } +} + +func copyReviewFields(fields map[string]dbus.Variant) map[string]dbus.Variant { + copy := make(map[string]dbus.Variant, len(fields)) + for key, value := range fields { + copy[key] = value + } + return copy +} +func DecodeReviewSnapshot(fields map[string]dbus.Variant) (ReviewSnapshot, error) { + var snapshot ReviewSnapshot + if len(fields) != 8 { + return ReviewSnapshot{}, ErrReviewInvalid + } + reads := []struct { + key, signature string + destination any + }{ + {"version", "u", &snapshot.Version}, {"instance", "s", &snapshot.Instance}, {"window", "s", &snapshot.Window}, + {"focus", "s", &snapshot.Focus}, {"revision", "t", &snapshot.Revision}, {"status", "s", &snapshot.Status}, {"bounds", "(iiii)", &snapshot.Bounds}, + } + for _, read := range reads { + if err := reviewField(fields, read.key, read.signature, read.destination); err != nil { + return ReviewSnapshot{}, err + } + } + var nodes []map[string]dbus.Variant + // Check the outer shape and count before copying nested records. + value, found := fields["nodes"] + if !found || value.Signature().String() != "aa{sv}" { + return ReviewSnapshot{}, ErrReviewInvalid + } + nodes, found = value.Value().([]map[string]dbus.Variant) + if !found || len(nodes) > 4096 { + return ReviewSnapshot{}, ErrReviewInvalid + } + canonical := copyReviewFields(fields) + canonical["bounds"] = dbus.MakeVariant(snapshot.Bounds) + canonicalNodes := make([]map[string]dbus.Variant, 0, len(nodes)) + for _, fields := range nodes { + if len(fields) != 12 { + return ReviewSnapshot{}, ErrReviewInvalid + } + var node ReviewNode + reads := []struct { + key, signature string + destination any + }{ + {"id", "s", &node.ID}, {"parent", "s", &node.Parent}, {"role", "s", &node.Role}, {"name", "s", &node.Name}, + {"protected", "b", &node.Protected}, {"visible", "b", &node.Visible}, {"text_known", "b", &node.TextKnown}, + {"caret_known", "b", &node.CaretKnown}, {"caret", "t", &node.Caret}, {"caret_utf16", "t", &node.CaretUTF16}, + {"bounds", "(iiii)", &node.Bounds}, {"ranges", "a(tttts(iiii))", &node.Ranges}, + } + for _, read := range reads { + if err := reviewField(fields, read.key, read.signature, read.destination); err != nil { + return ReviewSnapshot{}, err + } + } + snapshot.Nodes = append(snapshot.Nodes, node) + encoded := copyReviewFields(fields) + encoded["bounds"] = dbus.MakeVariant(node.Bounds) + encoded["ranges"] = dbus.MakeVariant(node.Ranges) + canonicalNodes = append(canonicalNodes, encoded) + } + if err := validateReviewSnapshot(snapshot); err != nil { + snapshot.Nodes = nil + return snapshot, err + } + // Exact D-Bus encoding catches metadata overhead after the independent + // record/text budgets bound the encoder's temporary allocation. + counter := reviewByteCounter{} + canonical["nodes"] = dbus.MakeVariant(canonicalNodes) + message := dbus.Message{Type: dbus.TypeMethodReply, Headers: map[dbus.HeaderField]dbus.Variant{ + dbus.FieldReplySerial: dbus.MakeVariant(uint32(1)), dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf(fields)), + }, Body: []any{canonical}} + if err := message.EncodeTo(&counter, binary.LittleEndian); err != nil || counter.count > 2<<20 { + return ReviewSnapshot{}, ErrReviewInvalid + } + return snapshot, nil +} + +type reviewByteCounter struct{ count int } + +func (counter *reviewByteCounter) Write(data []byte) (int, error) { + counter.count += len(data) + return len(data), nil +} + +func validateReviewSnapshot(snapshot ReviewSnapshot) error { + if snapshot.Version != ReviewProtocolVersion || !reviewString(snapshot.Instance, 256, false) || + !reviewString(snapshot.Window, 256, false) || !reviewString(snapshot.Focus, 256, false) || snapshot.Revision == 0 || !snapshot.Bounds.Valid() || len(snapshot.Nodes) > 4096 { + return ErrReviewInvalid + } + switch snapshot.Status { + case "ok": + case "unavailable", "stale", "busy", "limited": + if len(snapshot.Nodes) != 0 { + return ErrReviewInvalid + } + switch snapshot.Status { + case "stale": + return ErrReviewStale + case "busy": + return ErrReviewBusy + case "limited": + return ErrReviewLimited + default: + return ErrReviewUnavailable + } + default: + return ErrReviewInvalid + } + if len(snapshot.Nodes) == 0 { + return ErrReviewInvalid + } + nodes := make(map[string]ReviewNode, len(snapshot.Nodes)) + textBytes, metadataFloor := 0, 256 + for _, node := range snapshot.Nodes { + if !reviewString(node.ID, 256, false) || !reviewString(node.Parent, 256, true) || !reviewString(node.Role, 4096, false) || + !reviewString(node.Name, 4096, true) || !node.Bounds.Valid() || len(node.Ranges) > 256 { + return ErrReviewInvalid + } + if _, exists := nodes[node.ID]; exists { + return ErrReviewInvalid + } + nodes[node.ID] = node + if node.Protected && (node.Name != "" || node.TextKnown || node.CaretKnown || len(node.Ranges) != 0 || node.Caret != 0 || node.CaretUTF16 != 0) { + return ErrReviewInvalid + } + if !node.Visible && (node.Name != "" || node.TextKnown || node.CaretKnown || len(node.Ranges) != 0) { + return ErrReviewInvalid + } + if !node.TextKnown && (len(node.Ranges) != 0 || node.CaretKnown) || !node.CaretKnown && (node.Caret != 0 || node.CaretUTF16 != 0) { + return ErrReviewInvalid + } + if node.Caret > maxReviewSourceUnits || node.CaretUTF16 > maxReviewSourceUnits || node.CaretUTF16 < node.Caret || node.CaretUTF16 > 2*node.Caret { + return ErrReviewInvalid + } + previous, nativePrevious, characters := uint64(0), uint64(0), uint64(0) + textBytes += len(node.Name) + metadataFloor += 200 + len(node.ID) + len(node.Parent) + len(node.Role) + len(node.Name) + for _, r := range node.Ranges { + if !utf8.ValidString(r.Text) || strings.ContainsAny(r.Text, "\x00\ufffc") || r.Start < previous || r.End <= r.Start || + r.UTF16Start < nativePrevious || r.UTF16End <= r.UTF16Start || r.End > maxReviewSourceUnits || r.UTF16End > maxReviewSourceUnits || + r.UTF16Start < r.Start || r.UTF16Start > 2*r.Start || r.UTF16End < r.End || r.UTF16End > 2*r.End { + return ErrReviewInvalid + } + count := uint64(utf8.RuneCountInString(r.Text)) + if count != r.End-r.Start || count > 4096 || uint64(len(utf16.Encode([]rune(r.Text)))) != r.UTF16End-r.UTF16Start { + return ErrReviewInvalid + } + if strings.ContainsAny(r.Text, "\r\n") && strings.Trim(r.Text, "\r\n") != "" { + return ErrReviewInvalid + } + if clipped, ok := r.Bounds.Intersection(node.Bounds); !ok || clipped != r.Bounds { + return ErrReviewInvalid + } + previous, nativePrevious = r.End, r.UTF16End + characters += count + textBytes += len(r.Text) + metadataFloor += 64 + len(r.Text) + } + if characters > 65536 || textBytes > 1<<20 || metadataFloor > 2<<20 { + return ErrReviewInvalid + } + } + root, exists := nodes[snapshot.Window] + if !exists || root.Parent != "" || (root.Role != "window" && root.Role != "dialog") || !root.Visible || root.Protected || root.Bounds != snapshot.Bounds { + return ErrReviewInvalid + } + if _, exists := nodes[snapshot.Focus]; !exists { + return ErrReviewInvalid + } + // Check each ancestry chain: this bounds depth and detects disconnected + // cycles, which a traversal starting only at the root would miss. + for id, node := range nodes { + if id == snapshot.Window { + continue + } + seen := map[string]bool{id: true} + for depth := 1; ; depth++ { + if depth > 64 { + return ErrReviewInvalid + } + parent, exists := nodes[node.Parent] + if !exists || seen[node.Parent] { + return ErrReviewInvalid + } + if clipped, ok := node.Bounds.Intersection(parent.Bounds); !ok || clipped != node.Bounds { + return ErrReviewInvalid + } + if parent.ID == snapshot.Window { + break + } + seen[parent.ID] = true + node = parent + } + } + return nil +} + +func ApplyReviewSnapshot(tree *atspi.AccessibleTree, snapshot ReviewSnapshot, sender string) (*atspi.AccessibleTree, atspi.ObjectID, error) { + if err := validateReviewSnapshot(snapshot); err != nil { + return nil, atspi.ObjectID{}, err + } + root := ReviewID(sender, snapshot.Instance, snapshot.Window, snapshot.Window) + if !IsReviewID(root) { + return nil, atspi.ObjectID{}, ErrReviewInvalid + } + nodes := make([]atspi.TreeNode, len(snapshot.Nodes)) + index := make(map[string]int, len(nodes)) + for i, node := range snapshot.Nodes { + id := ReviewID(sender, snapshot.Instance, snapshot.Window, node.ID) + index[node.ID] = i + role := node.Role + if node.Protected { + role = "password text" + } + geometry := atspi.Geometry{VisibilityKnown: true, Visible: node.Visible, Showing: node.Visible} + if node.Visible { + geometry.BoundsKnown = true + geometry.Bounds = node.Bounds + } + text := atspi.VisibleText{Known: node.TextKnown, CaretKnown: node.CaretKnown, CaretOffset: int(node.Caret)} + for _, r := range node.Ranges { + text.Ranges = append(text.Ranges, atspi.TextRange{Start: int(r.Start), End: int(r.End), Text: r.Text, Bounds: r.Bounds}) + } + nodes[i] = atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: id.BusName, Path: id.Path, Name: node.Name, Role: role, + StatesKnown: true, Visible: node.Visible, Showing: node.Visible, Focused: node.ID == snapshot.Focus, Focusable: node.ID == snapshot.Focus}, Geometry: geometry, Text: text} + } + for _, node := range snapshot.Nodes { + if node.Parent != "" { + parent := index[node.Parent] + nodes[parent].Children = append(nodes[parent].Children, ReviewID(sender, snapshot.Instance, snapshot.Window, node.ID)) + } + } + var err error + if tree == nil { + tree, err = atspi.NewAccessibleTree(root, nodes) + } else { + err = tree.Replace(root, nodes) + } + if err != nil { + return nil, atspi.ObjectID{}, fmt.Errorf("%w: %v", ErrReviewInvalid, err) + } + return tree, ReviewID(sender, snapshot.Instance, snapshot.Window, snapshot.Focus), nil +} diff --git a/internal/wine/review_snapshot_test.go b/internal/wine/review_snapshot_test.go new file mode 100644 index 0000000..0bc7161 --- /dev/null +++ b/internal/wine/review_snapshot_test.go @@ -0,0 +1,352 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package wine + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "github.com/godbus/dbus/v5" +) + +// These records describe the producer's wire contract independently of the +// decoder's types. This also exercises godbus struct-to-struct conversion. +type testWireRange struct { + Start, End, UTF16Start, UTF16End uint64 + Text string + Bounds atspi.Rectangle +} + +func reviewWireNode(id, parent, role, name string, bounds atspi.Rectangle, known bool, ranges ...testWireRange) map[string]dbus.Variant { + return map[string]dbus.Variant{ + "id": dbus.MakeVariant(id), "parent": dbus.MakeVariant(parent), "role": dbus.MakeVariant(role), "name": dbus.MakeVariant(name), + "protected": dbus.MakeVariant(false), "visible": dbus.MakeVariant(true), "bounds": dbus.MakeVariant(bounds), + "text_known": dbus.MakeVariant(known), "caret_known": dbus.MakeVariant(false), "caret": dbus.MakeVariant(uint64(0)), + "caret_utf16": dbus.MakeVariant(uint64(0)), "ranges": dbus.MakeVariant(ranges), + } +} +func reviewWire() map[string]dbus.Variant { + root := atspi.Rectangle{X: 10, Y: 20, Width: 400, Height: 300} + input := reviewWireNode("input", "window", "text", "", atspi.Rectangle{X: 20, Y: 40, Width: 200, Height: 30}, true, + testWireRange{0, 11, 0, 12, "😀alpha beta", atspi.Rectangle{X: 20, Y: 40, Width: 140, Height: 20}}) + input["caret_known"] = dbus.MakeVariant(true) + input["caret"] = dbus.MakeVariant(uint64(2)) + input["caret_utf16"] = dbus.MakeVariant(uint64(3)) + password := reviewWireNode("password", "window", "password text", "", atspi.Rectangle{X: 20, Y: 160, Width: 120, Height: 25}, false) + password["protected"] = dbus.MakeVariant(true) + nodes := []map[string]dbus.Variant{ + reviewWireNode("window", "", "window", "Review fixture", root, false), input, + reviewWireNode("output", "window", "text", "", atspi.Rectangle{X: 20, Y: 100, Width: 200, Height: 50}, true, + testWireRange{7, 11, 8, 12, "beta", atspi.Rectangle{X: 20, Y: 100, Width: 80, Height: 20}}), password, + reviewWireNode("button", "window", "push button", "Open & close", atspi.Rectangle{X: 20, Y: 210, Width: 120, Height: 25}, false), + } + return map[string]dbus.Variant{"version": dbus.MakeVariant(uint32(1)), "instance": dbus.MakeVariant("helper-instance"), + "window": dbus.MakeVariant("window"), "focus": dbus.MakeVariant("input"), "revision": dbus.MakeVariant(uint64(1)), + "status": dbus.MakeVariant("ok"), "bounds": dbus.MakeVariant(root), "nodes": dbus.MakeVariant(nodes)} +} +func wireNodes(fields map[string]dbus.Variant) []map[string]dbus.Variant { + return fields["nodes"].Value().([]map[string]dbus.Variant) +} + +func TestReviewSnapshotUnicodeAndStableTree(t *testing.T) { + fields := reviewWire() + snapshot, err := DecodeReviewSnapshot(fields) + if err != nil { + t.Fatal(err) + } + tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20") + if err != nil { + t.Fatal(err) + } + if !focus.Valid() || !IsReviewID(focus) || !strings.HasPrefix(focus.Path, "/wine/") { + t.Fatalf("invalid Wine identity: %v", focus) + } + reference, err := tree.Reference(focus) + if err != nil { + t.Fatal(err) + } + visible, err := tree.VisibleText(reference) + if err != nil { + t.Fatal(err) + } + if !visible.CaretKnown || visible.CaretOffset != 2 || len(visible.Ranges) != 1 || visible.Ranges[0].End != 11 || visible.Ranges[0].Text != "😀alpha beta" { + t.Fatalf("bad Unicode capture: %+v", visible) + } + original := reference + input := wireNodes(fields)[1] + input["ranges"] = dbus.MakeVariant([]testWireRange{{0, 11, 0, 12, "😀omega zeta", atspi.Rectangle{X: 20, Y: 40, Width: 140, Height: 20}}}) + snapshot, err = DecodeReviewSnapshot(fields) + if err != nil { + t.Fatal(err) + } + updated, again, err := ApplyReviewSnapshot(tree, snapshot, ":1.20") + if err != nil { + t.Fatal(err) + } + if updated != tree || again != focus { + t.Fatal("text update replaced the owned tree or focus identity") + } + after, _ := tree.Reference(focus) + if after != original { + t.Fatal("text update retired a live generation") + } + visible, _ = tree.VisibleText(after) + if visible.Ranges[0].Text != "😀omega zeta" { + t.Fatal("tree retained old text") + } + // Removing and then reusing an identity must not revive its old reference. + nodes := wireNodes(fields) + fields["focus"] = dbus.MakeVariant("output") + fields["nodes"] = dbus.MakeVariant(append(append([]map[string]dbus.Variant{}, nodes[:1]...), nodes[2:]...)) + snapshot, err = DecodeReviewSnapshot(fields) + if err != nil { + t.Fatal(err) + } + if _, _, err = ApplyReviewSnapshot(tree, snapshot, ":1.20"); err != nil { + t.Fatal(err) + } + fields["nodes"] = dbus.MakeVariant(nodes) + fields["focus"] = dbus.MakeVariant("input") + snapshot, _ = DecodeReviewSnapshot(fields) + if _, _, err = ApplyReviewSnapshot(tree, snapshot, ":1.20"); err != nil { + t.Fatal(err) + } + after, _ = tree.Reference(focus) + if after == original { + t.Fatal("removed identity reused an old generation") + } + if _, err = tree.Resolve(original); !errors.Is(err, atspi.ErrStaleObject) { + t.Fatalf("removed reference stayed usable: %v", err) + } + // The unique sender is part of every owned identity. + _, other, err := ApplyReviewSnapshot(nil, snapshot, ":1.21") + if err != nil || other == focus { + t.Fatalf("providers collided: %v", err) + } + for _, id := range []atspi.ObjectID{atspi.NewObjectID(":1.20", "/wineish/a/b/c"), atspi.NewObjectID(":1.20", "/wine/x/y/z"), atspi.NewObjectID("org.test.App", focus.Path)} { + if IsReviewID(id) { + t.Fatalf("reserved namespace accepted invalid identity: %+v", id) + } + } +} + +func TestReviewSnapshotEmptyAnchor(t *testing.T) { + fields := reviewWire() + input := wireNodes(fields)[1] + input["ranges"] = dbus.MakeVariant([]testWireRange{}) + input["caret"] = dbus.MakeVariant(uint64(0)) + input["caret_utf16"] = dbus.MakeVariant(uint64(0)) + snapshot, err := DecodeReviewSnapshot(fields) + if err != nil { + t.Fatal(err) + } + tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20") + if err != nil { + t.Fatal(err) + } + ref, err := tree.Reference(focus) + if err != nil { + t.Fatal(err) + } + text, err := tree.VisibleText(ref) + if err != nil || !text.Known || len(text.Ranges) != 0 { + t.Fatalf("empty anchor lost: %+v %v", text, err) + } + window, err := tree.ReviewWindow(context.Background(), focus) + if err != nil || len(window.Nodes) != 5 { + t.Fatalf("empty anchor hid output: %v", err) + } +} + +func TestReviewSnapshotProtectedContent(t *testing.T) { + fields := reviewWire() + fields["focus"] = dbus.MakeVariant("password") + snapshot, err := DecodeReviewSnapshot(fields) + if err != nil { + t.Fatal(err) + } + tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20") + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Reference(focus) + object, _ := tree.Resolve(ref) + text, _ := tree.VisibleText(ref) + if object.Role != "password text" || object.Name != "" || object.Text != "" || text.Known || len(text.Ranges) != 0 { + t.Fatalf("protected object exposed text: %+v %+v", object, text) + } + wireNodes(fields)[3]["name"] = dbus.MakeVariant("private-review-secret") + rejected, err := DecodeReviewSnapshot(fields) + if !errors.Is(err, ErrReviewInvalid) || len(rejected.Nodes) != 0 { + t.Fatal("protected name accepted") + } +} + +func TestReviewSnapshotRejectsInvalidPayload(t *testing.T) { + tests := map[string]func(map[string]dbus.Variant){ + "forged scalar signature": func(f map[string]dbus.Variant) { + f["revision"] = dbus.MakeVariantWithSignature(int64(1), dbus.ParseSignatureMust("t")) + }, + "forged tuple signature": func(f map[string]dbus.Variant) { + f["bounds"] = dbus.MakeVariantWithSignature([]int64{10, 20, 400, 300}, dbus.ParseSignatureMust("(iiii)")) + }, + "version": func(f map[string]dbus.Variant) { f["version"] = dbus.MakeVariant(uint32(2)) }, + "wrong scalar": func(f map[string]dbus.Variant) { f["revision"] = dbus.MakeVariant(int64(1)) }, + "missing key": func(f map[string]dbus.Variant) { delete(f, "focus") }, + "extra key": func(f map[string]dbus.Variant) { f["extra"] = dbus.MakeVariant("not part of version one") }, + "invalid utf8": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant(string([]byte{255})) }, + "nul identity": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant("a\x00b") }, + "identity bound": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant(strings.Repeat("a", 257)) }, + "empty identity": func(f map[string]dbus.Variant) { f["instance"] = dbus.MakeVariant("") }, + "unknown status": func(f map[string]dbus.Variant) { f["status"] = dbus.MakeVariant("partial") }, + "nonok nodes": func(f map[string]dbus.Variant) { f["status"] = dbus.MakeVariant("limited") }, + "invalid bounds": func(f map[string]dbus.Variant) { f["bounds"] = dbus.MakeVariant(atspi.Rectangle{}) }, + "bounds shape": func(f map[string]dbus.Variant) { f["bounds"] = dbus.MakeVariant([]int32{1, 2, 3, 4}) }, + "nodes shape": func(f map[string]dbus.Variant) { f["nodes"] = dbus.MakeVariant([]string{"input"}) }, + "no nodes": func(f map[string]dbus.Variant) { f["nodes"] = dbus.MakeVariant([]map[string]dbus.Variant{}) }, + "duplicate": func(f map[string]dbus.Variant) { f["nodes"] = dbus.MakeVariant(append(wireNodes(f), wireNodes(f)[1])) }, + "orphan": func(f map[string]dbus.Variant) { wireNodes(f)[1]["parent"] = dbus.MakeVariant("missing") }, + "cycle": func(f map[string]dbus.Variant) { + wireNodes(f)[1]["parent"] = dbus.MakeVariant("output") + wireNodes(f)[2]["parent"] = dbus.MakeVariant("input") + }, + "missing focus": func(f map[string]dbus.Variant) { f["focus"] = dbus.MakeVariant("missing") }, + "root parent": func(f map[string]dbus.Variant) { wireNodes(f)[0]["parent"] = dbus.MakeVariant("input") }, + "root role": func(f map[string]dbus.Variant) { wireNodes(f)[0]["role"] = dbus.MakeVariant("text") }, + "node type": func(f map[string]dbus.Variant) { wireNodes(f)[1]["caret_known"] = dbus.MakeVariant(uint32(1)) }, + "node missing key": func(f map[string]dbus.Variant) { delete(wireNodes(f)[1], "ranges") }, + "caption bound": func(f map[string]dbus.Variant) { wireNodes(f)[4]["name"] = dbus.MakeVariant(strings.Repeat("a", 4097)) }, + "invisible text": func(f map[string]dbus.Variant) { wireNodes(f)[1]["visible"] = dbus.MakeVariant(false) }, + "unknown text with ranges": func(f map[string]dbus.Variant) { wireNodes(f)[1]["text_known"] = dbus.MakeVariant(false) }, + "unknown caret": func(f map[string]dbus.Variant) { wireNodes(f)[1]["caret_known"] = dbus.MakeVariant(false) }, + "caret overflow": func(f map[string]dbus.Variant) { wireNodes(f)[1]["caret"] = dbus.MakeVariant(uint64(math.MaxUint64)) }, + "ranges shape": func(f map[string]dbus.Variant) { wireNodes(f)[1]["ranges"] = dbus.MakeVariant([]string{"bad"}) }, + } + rangeCases := map[string]testWireRange{ + "rune count": {0, 5, 0, 5, "four", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}, + "offset overflow": {math.MaxUint64, 0, 0, 1, "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}, + "utf16 span": {0, 1, 0, 1, "😀", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}, + "native offset overflow": {0, 1, math.MaxUint64, 0, "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}, + "clipped range": {0, 1, 0, 1, "a", atspi.Rectangle{X: -1, Y: 40, Width: 80, Height: 20}}, + "mixed newline": {0, 2, 0, 2, "a\n", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}, + "line bound": {0, 4097, 0, 4097, strings.Repeat("a", 4097), atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}}, + } + for name, r := range rangeCases { + tests[name] = func(f map[string]dbus.Variant) { wireNodes(f)[1]["ranges"] = dbus.MakeVariant([]testWireRange{r}) } + } + tests["node bound"] = func(f map[string]dbus.Variant) { + nodes := wireNodes(f) + for len(nodes) < 4097 { + nodes = append(nodes, reviewWireNode(fmt.Sprintf("node_%d", len(nodes)), "window", "panel", "", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}, false)) + } + f["nodes"] = dbus.MakeVariant(nodes) + } + tests["range bound"] = func(f map[string]dbus.Variant) { + ranges := make([]testWireRange, 257) + for i := range ranges { + ranges[i] = testWireRange{uint64(i), uint64(i + 1), uint64(i), uint64(i + 1), "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}} + } + wireNodes(f)[1]["ranges"] = dbus.MakeVariant(ranges) + } + tests["control character bound"] = func(f map[string]dbus.Variant) { + ranges := make([]testWireRange, 17) + for i := range ranges { + s := uint64(i * 4096) + ranges[i] = testWireRange{s, s + 4096, s, s + 4096, strings.Repeat("a", 4096), atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}} + } + wireNodes(f)[1]["ranges"] = dbus.MakeVariant(ranges) + } + tests["depth bound"] = func(f map[string]dbus.Variant) { + nodes := wireNodes(f) + parent := "input" + for i := 0; i < 65; i++ { + id := strings.Repeat("d", i+1) + nodes = append(nodes, reviewWireNode(id, parent, "panel", "", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}, false)) + parent = id + } + f["nodes"] = dbus.MakeVariant(nodes) + } + tests["window text bound"] = func(f map[string]dbus.Variant) { + nodes := wireNodes(f) + for n := 0; n < 17; n++ { + ranges := make([]testWireRange, 16) + for i := range ranges { + s := uint64(i * 4096) + ranges[i] = testWireRange{s, s + 4096, s, s + 4096, strings.Repeat("a", 4096), atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}} + } + nodes = append(nodes, reviewWireNode(fmt.Sprintf("large_%d", n), "window", "text", "", atspi.Rectangle{X: 20, Y: 40, Width: 200, Height: 30}, true, ranges...)) + } + f["nodes"] = dbus.MakeVariant(nodes) + } + tests["serialized bound"] = func(f map[string]dbus.Variant) { + nodes := wireNodes(f) + for n := 0; n < 128; n++ { + ranges := make([]testWireRange, 256) + for i := range ranges { + s := uint64(i) + ranges[i] = testWireRange{s, s + 1, s, s + 1, "a", atspi.Rectangle{X: 20, Y: 40, Width: 80, Height: 20}} + } + nodes = append(nodes, reviewWireNode(fmt.Sprintf("many_%d", n), "window", "text", "", atspi.Rectangle{X: 20, Y: 40, Width: 200, Height: 30}, true, ranges...)) + } + f["nodes"] = dbus.MakeVariant(nodes) + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fields := reviewWire() + mutate(fields) + snapshot, err := DecodeReviewSnapshot(fields) + if !errors.Is(err, ErrReviewInvalid) || len(snapshot.Nodes) != 0 { + t.Fatalf("invalid payload accepted: nodes=%d err=%v", len(snapshot.Nodes), err) + } + }) + } +} + +func TestReviewSnapshotActualBusStructs(t *testing.T) { + fields := reviewWire() + message := dbus.Message{Type: dbus.TypeMethodReply, Headers: map[dbus.HeaderField]dbus.Variant{ + dbus.FieldReplySerial: dbus.MakeVariant(uint32(1)), dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf(fields)), + }, Body: []any{fields}} + var wire bytes.Buffer + if err := message.EncodeTo(&wire, binary.LittleEndian); err != nil { + t.Fatal(err) + } + decoded, err := dbus.DecodeMessage(&wire) + if err != nil { + t.Fatal(err) + } + snapshot, err := DecodeReviewSnapshot(decoded.Body[0].(map[string]dbus.Variant)) + if err != nil { + t.Fatal(err) + } + tree, focus, err := ApplyReviewSnapshot(nil, snapshot, ":1.20") + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Reference(focus) + text, _ := tree.VisibleText(ref) + if text.Ranges[0].Text != "😀alpha beta" { + t.Fatalf("actual bus structs lost text: %+v", text) + } +} + +func TestReviewSnapshotStatuses(t *testing.T) { + for status, want := range map[string]error{"unavailable": ErrReviewUnavailable, "stale": ErrReviewStale, "busy": ErrReviewBusy, "limited": ErrReviewLimited} { + t.Run(status, func(t *testing.T) { + fields := reviewWire() + fields["status"] = dbus.MakeVariant(status) + fields["nodes"] = dbus.MakeVariant([]map[string]dbus.Variant{}) + snapshot, err := DecodeReviewSnapshot(fields) + if !errors.Is(err, want) || len(snapshot.Nodes) != 0 { + t.Fatalf("status %s: %v", status, err) + } + }) + } +} diff --git a/sdk/go/tentacle/tentacle.go b/sdk/go/tentacle/tentacle.go new file mode 100644 index 0000000..67f4fed --- /dev/null +++ b/sdk/go/tentacle/tentacle.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package tentacle implements the experimental command, presentation and interaction SDK. +// It has no reader, input, or output backend dependencies. +package tentacle + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "strconv" + "sync" + + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type Binding struct { + Layout string `json:"layout"` + Scope string `json:"scope"` + Key string `json:"key"` +} + +// Presentation returns independent plain-text speech and braille messages. +// Declare Command.Presentation and obtain the local host grant before using it. +// Uninterruptible protects accepted speech through ordinary input/focus changes; +// session revocation and core lifecycle/output policy still take precedence. +type Presentation = tentaclewire.Presentation + +// Interaction requires the explicit interaction grant as well as presentation. +type Interaction = tentaclewire.Interaction +type Submission = tentaclewire.Submission +type Item = tentaclewire.Item +type Form = interaction.Form +type Element = interaction.Element +type ListItem = interaction.Item +type Value = interaction.Value + +const ( + List = interaction.List + Entry = interaction.Entry + CheckBox = interaction.CheckBox + Button = interaction.Button +) + +// Handlers may run concurrently. Cancellation applies to the entire session; +// check ctx before side effects. Returning an error produces a generic RPC error, +// never the error text (which may contain private data). +type Handler func(ctx context.Context, arguments json.RawMessage) (any, error) + +type Command struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Bindings []Binding `json:"bindings,omitempty"` + Presentation bool `json:"presentation,omitempty"` + Interaction bool `json:"interaction,omitempty"` + // Settings exposes this interactive command through global Preferences. + // Register at most one; its handler opens a form without requiring arguments. + Settings bool `json:"settings,omitempty"` + Handle Handler `json:"-"` +} + +var ErrProtocol = tentaclewire.ErrProtocol + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +// Serve owns the streams and closes them on return. Use stdin/stdout in a +// dedicated child process and exit when Serve returns. Shutdown/EOF cancels all +// handlers and waits for them; the host kills a child that ignores cancellation. +// Never print to stdout. Command declarations must remain unchanged during Serve. +func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error { + ctx, cancel := context.WithCancel(context.Background()) + var workers sync.WaitGroup + var writeMutex sync.Mutex + defer func() { cancel(); input.Close(); output.Close(); workers.Wait() }() + handlers := make(map[string]Handler) + if len(commands) == 0 || len(commands) > 32 { + return ErrProtocol + } + settings := false + for _, command := range commands { + if command.Settings && (!command.Interaction || !command.Presentation || settings) || command.ID == "" || command.Handle == nil || handlers[command.ID] != nil { + return ErrProtocol + } + settings = settings || command.Settings + handlers[command.ID] = command.Handle + } + writeReply := func(id string, value any, failed bool, release func()) error { + reply := map[string]any{"jsonrpc": "2.0", "id": id, "result": value} + if failed { + delete(reply, "result") + reply["error"] = map[string]any{"code": -32603, "message": "Command failed"} + } + frame, err := json.Marshal(reply) + if err != nil || len(frame) > tentaclewire.MaxFrameBytes || tentaclewire.ValidateJSON(frame) != nil { + frame, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, + "error": map[string]any{"code": -32603, "message": "Invalid or oversized result"}}) + } + writeMutex.Lock() + defer writeMutex.Unlock() + // A completed reply may reach the host before Write returns. Release + // its handler slot first, with at most one additional serialized writer. + if release != nil { + release() + } + if ctx.Err() != nil { + return ctx.Err() + } + _, err = output.Write(append(frame, '\n')) + return err + } + reader := bufio.NewReader(input) + var sequence uint64 + session := "" + active := false + slots := make(chan struct{}, 8) + for { + frame, err := tentaclewire.ReadFrame(reader) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + var message request + if tentaclewire.DecodeStrict(frame, &message) != nil || message.JSONRPC != "2.0" { + return ErrProtocol + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(frame, &fields) + for key := range fields { + if key != "jsonrpc" && key != "id" && key != "method" && key != "params" { + return ErrProtocol + } + } + if _, present := fields["id"]; present && message.ID == "" { + return ErrProtocol + } + if message.ID != "" { + number, err := strconv.ParseUint(message.ID, 10, 64) + if err != nil || number <= sequence || strconv.FormatUint(number, 10) != message.ID { + return ErrProtocol + } + sequence = number + } + switch message.Method { + case "initialize": + var params struct { + Protocol struct { + Major *int `json:"major"` + Minor *int `json:"minor"` + } `json:"protocol"` + Session string `json:"session"` + APIs []string `json:"apis"` + Capabilities []string `json:"capabilities"` + } + if session != "" || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || + params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || *params.Protocol.Minor != 0 || params.Session == "" || len(params.Session) > 128 || + !tentaclewire.ValidAPIs(params.APIs, params.Capabilities) { + return ErrProtocol + } + for _, command := range commands { + if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) != 3) { + return ErrProtocol + } + } + session = params.Session + if err := writeReply(message.ID, map[string]any{"protocol": params.Protocol, "session": session, "commands": commands}, false, nil); err != nil { + return err + } + case "initialized": + var params struct { + Session string `json:"session"` + } + if session == "" || active || message.ID != "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || params.Session != session { + return ErrProtocol + } + active = true + case "command.invoke": + var params struct { + Command string `json:"command"` + Arguments json.RawMessage `json:"arguments"` + } + if !active || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || len(params.Arguments) == 0 || len(params.Arguments) > 16*1024 || handlers[params.Command] == nil { + return ErrProtocol + } + select { + case slots <- struct{}{}: + default: + return tentaclewire.ErrLimit + } + workers.Add(1) + go func(id string, handler Handler, arguments json.RawMessage) { + defer workers.Done() + released := false + release := func() { + if !released { + <-slots + released = true + } + } + defer release() + value, err := call_handler(ctx, handler, arguments) + if writeReply(id, value, err != nil, release) != nil { + cancel() + input.Close() + } + }(message.ID, handlers[params.Command], params.Arguments) + case "shutdown": + if message.ID != "" || (len(message.Params) != 0 && string(message.Params) != "null") { + return ErrProtocol + } + return nil + default: + return ErrProtocol + } + } +} + +func call_handler(ctx context.Context, handler Handler, arguments json.RawMessage) (value any, err error) { + defer func() { + if recover() != nil { + value = nil + err = errors.New("command panicked") + } + }() + return handler(ctx, arguments) +} diff --git a/sdk/python/cthulhu_tentacle/__init__.py b/sdk/python/cthulhu_tentacle/__init__.py new file mode 100644 index 0000000..a2a8c24 --- /dev/null +++ b/sdk/python/cthulhu_tentacle/__init__.py @@ -0,0 +1,214 @@ +# Copyright (c) 2026 Storm Dragon +# SPDX-License-Identifier: GPL-3.0-or-later +"""Experimental command/presentation SDK. stdout is reserved for the protocol.""" + +import json +import sys +import threading +from dataclasses import dataclass, field + +MAX_FRAME = 64 * 1024 + + +class ProtocolError(Exception): + pass + + +@dataclass +class Command: + id: str + title: str + description: str + handle: object + bindings: list = field(default_factory=list) + presentation: bool = False + + def declaration(self): + declaration = {"id": self.id, "title": self.title, "description": self.description, + "bindings": self.bindings} + if self.presentation: + declaration["presentation"] = True + return declaration + + +def present(speech="", braille="", uninterruptible=False): + """Return independent text messages from a presentation-enabled command.""" + if type(uninterruptible) is not bool or (uninterruptible and not speech): + raise ValueError("Uninterruptible output requires speech and a boolean option") + result = {key: text for key, text in (("speech", speech), ("braille", braille)) if text} + if uninterruptible: + result["uninterruptible"] = True + return result + + +def _object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ProtocolError("Duplicate key") + result[key] = value + return result + + +def _depth(value, depth=0): + if depth > 32: + raise ProtocolError("JSON nesting limit") + if isinstance(value, (dict, list)): + for child in value.values() if isinstance(value, dict) else value: + _depth(child, depth + 1) + + +def _constant(value): + raise ProtocolError("Invalid JSON number") + + +def _encode(value): + _depth(value) + return json.dumps(value, ensure_ascii=False, allow_nan=False, + separators=(",", ":")).encode("utf-8") + + +def _fields(value, required, optional=()): + if (not isinstance(value, dict) or not set(required) <= value.keys() + or value.keys() - set(required) - set(optional)): + raise ProtocolError("Invalid fields") + + +def serve(commands, inputStream=None, outputStream=None): + """Run in a dedicated child, then exit. Handlers receive (cancelled, arguments). + + cancelled is a threading.Event shared by the session. Up to eight handlers + run concurrently. Shutdown/EOF sets it and joins handlers; the host terminates + children that ignore cancellation. Exceptions become generic RPC errors. + Streams are binary, owned by this call, and closed on return. + """ + inputStream = inputStream if inputStream is not None else sys.stdin.buffer + outputStream = outputStream if outputStream is not None else sys.stdout.buffer + cancelled = threading.Event() + writeLock = threading.Lock() + workers = [] + slots = threading.BoundedSemaphore(8) + + def write_reply(requestId, value=None, failed=False, release=None): + reply = {"jsonrpc": "2.0", "id": requestId} + if failed: + reply["error"] = {"code": -32603, "message": "Command failed"} + else: + reply["result"] = value + try: + frame = _encode(reply) + if len(frame) > MAX_FRAME: + raise ValueError() + except (ValueError, TypeError, UnicodeError, RecursionError, ProtocolError): + frame = _encode({"jsonrpc": "2.0", "id": requestId, "error": { + "code": -32603, "message": "Invalid or oversized result"}}) + with writeLock: + # Bound eight handlers/waiting replies plus one serialized writer. + if release is not None: + release() + if not cancelled.is_set(): + outputStream.write(frame + b"\n") + outputStream.flush() + + def invoke(requestId, handler, arguments): + released = False + + def release_slot(): + nonlocal released + if not released: + slots.release() + released = True + + try: + value = handler(cancelled, arguments) + except BaseException: + value, failed = None, True + else: + failed = False + try: + write_reply(requestId, value, failed, release_slot) + except (OSError, ValueError): + cancelled.set() + finally: + release_slot() + + try: + commands = list(commands) + handlers = {command.id: command.handle for command in commands} + if (not 1 <= len(commands) <= 32 or len(handlers) != len(commands) + or any(not key or not callable(value) for key, value in handlers.items())): + raise ProtocolError("Invalid commands") + session, active, sequence = None, False, 0 + while not cancelled.is_set(): + frame = inputStream.readline(MAX_FRAME + 2) + if not frame: + return + if not frame.endswith(b"\n") or len(frame) > MAX_FRAME + 1: + raise ProtocolError("Invalid frame size or termination") + try: + message = json.loads(frame.decode("utf-8"), object_pairs_hook=_object, + parse_constant=_constant) + _depth(message) + except (ValueError, UnicodeError, RecursionError) as error: + raise ProtocolError("Invalid JSON") from error + _fields(message, ("jsonrpc", "method"), ("id", "params")) + if message["jsonrpc"] != "2.0": + raise ProtocolError("Invalid JSON-RPC version") + requestId = message.get("id", "") + if requestId: + if (not isinstance(requestId, str) or not requestId.isascii() + or not requestId.isdecimal() or len(requestId) > 20 + or not sequence < int(requestId) < 2**64 + or str(int(requestId)) != requestId): + raise ProtocolError("Invalid request sequence") + sequence = int(requestId) + elif "id" in message: + raise ProtocolError("Invalid request ID") + method, params = message["method"], message.get("params") + if method == "initialize": + _fields(params, ("protocol", "session", "apis", "capabilities")) + version = params["protocol"] + if (session is not None or not requestId or version != {"major": 1, "minor": 0} + or any(type(value) is not int for value in version.values()) + or not isinstance(params["session"], str) or not params["session"] + or len(params["session"].encode("utf-8")) > 128 + or params["apis"] not in (["commands"], ["commands", "presentation"]) + or params["capabilities"] != params["apis"] + or (any(command.presentation for command in commands) + and params["apis"] != ["commands", "presentation"])): + raise ProtocolError("Invalid initialization") + session = params["session"] + write_reply(requestId, {"protocol": version, "session": session, + "commands": [command.declaration() for command in commands]}) + elif method == "initialized": + _fields(params, ("session",)) + if session is None or active or requestId or params["session"] != session: + raise ProtocolError("Invalid activation") + active = True + elif method == "command.invoke": + _fields(params, ("command", "arguments")) + if (not active or not requestId or not isinstance(params["command"], str) + or params["command"] not in handlers + or len(_encode(params["arguments"])) > 16 * 1024): + raise ProtocolError("Invalid invocation") + workers = [worker for worker in workers if worker.is_alive()] + if not slots.acquire(blocking=False): + raise ProtocolError("Command limit") + worker = threading.Thread(target=invoke, args=(requestId, + handlers[params["command"]], params["arguments"])) + workers.append(worker) + worker.start() + elif method == "shutdown": + if requestId or params is not None: + raise ProtocolError("Invalid shutdown") + return + else: + raise ProtocolError("Unknown method") + finally: + cancelled.set() + # Join before closing buffered stdout: a blocked writer must remain + # subject to the host deadline rather than race a Python buffer close. + for worker in workers: + worker.join() + inputStream.close() + outputStream.close() diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv new file mode 100644 index 0000000..932a0ab --- /dev/null +++ b/testdata/parity-ledger.csv @@ -0,0 +1,56 @@ +id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes +gtk-alert-message,src/cthulhu/speech_generator.py;src/cthulhu/script_utilities.py,speech,Read a GTK alert message before its first focused button,,internal/atspi internal/app,in-progress,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private GTK/Xvfb/AT-SPI fixture captured the complete question on 2026-09-26.,,Physical FastSMRW listening pending.,,Alert window creation captures its label text; activation does not interrupt the message; the first button appends and later buttons use ordinary focus speech. +yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels. +radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame. +yad-list-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD list question when its window opens,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD power list captured Power options after ordinary or synthesized window activation.,,Physical I38 power menu listening pending.,,The I38 list has a labelled question above its choices; foreground recovery and direct activation both capture the same bounded prompt shape. +web-live-region-announcements,src/cthulhu/scripts/web/script.py; src/cthulhu/liveregions.py,web,Automatically speak active-tab polite and assertive live region updates,Storm Dragon,internal/atspi internal/app,in-progress,go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py,Private Brave/Xvfb fixture captures the Skald-style status update and real child text; physical Skald Xorg test pending.,,Pending human listening for Skald chat and urgent messages,,Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. +core-startup,src/cthulhu/cthulhu.py,core,Orchestrate runtime service startup and graceful shutdown,Storm Dragon,internal/app,in-progress,go test ./internal/app ./internal/atspi ./internal/lifecycle -count=1; CTHULHU_XRECORD_TEST=/tmp/cthulhu-xrecord.test python3 tests/native_x11.py,Maintainer accepted the Stage 1 physical Xorg shutdown checklist on 2026-09-11; post-fix cthulhu.sh returned to the prompt with complete shutdown records on Xorg using its LXTerminal test application. The private Xvfb/D-Bus native proof with XRecord also passed.,,,,"Lifecycle and service orchestration only; see lifecycle-welcome-message, lifecycle-farewell-message, lifecycle-start-sound, and lifecycle-stop-sound for the four user-visible deliveries. General startup or shutdown parity is not claimed." +lifecycle-welcome-message,src/cthulhu/plugins/HelloCthulhu/plugin.py,speech,Speak a randomly selected startup welcome message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer accepted welcome speech in the Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,,,Maintainer heard the startup welcome message during the accepted Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,Python presents Cthulhu welcomes you. once through HelloCthulhu after start-application-completed and only while an active script exists. Go creates its speech worker only when enableSpeech is true and randomly selects from a small startup list after service startup without an active-script or once-only gate; delivery is not waited for and has no per-message deadline. The extra variants are an intentional Go behavior extension. General startup parity is not claimed. +lifecycle-farewell-message,src/cthulhu/plugins/ByeCthulhu/plugin.py,speech,Speak a randomly selected graceful-shutdown farewell message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12; the launcher returned to the prompt and completed final shutdown records.,,,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,"Python presents Cthulhu lurks beneath the waves. through ByeCthulhu only while an active script exists. Go randomly selects from a small shutdown list only when enableSpeech is true, interrupts owned speech, and waits up to 2 seconds for the backend result before native cleanup; timeout cancels the active request. The extra variants are an intentional Go behavior extension. audit-required: active-script gating, speech-disabled presentation semantics, and Python's overall shutdown watchdog versus Go's per-message deadline are not directly aligned by the named evidence." +lifecycle-start-sound,src/cthulhu/sound_theme_manager.py,sound,Play the startup sound cue,Storm Dragon,internal/sound,in-progress,go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow)$' -count=1,Maintainer heard the startup cue during the 2026-09-11 physical Xorg preview; the desktop and window manager were not recorded.,,,,"Python plays the start cue from the configured theme when enableSound is true and the theme is not none; startup uses a nonblocking, interrupting, best-effort helper command with no per-cue deadline at this call. Go creates the sound service only when enableSound is true, theme is not none, and volume is greater than zero; it enqueues start after service startup into a bounded 8-cue queue, with a 3-second playback deadline, and can drop the startup cue on overflow. audit-required: Python helper-process and Go in-process queue saturation are not directly compared by live evidence. September 25 review: nonblocking queue drains prevent a consumer/producer deadlock during cue replacement and live reconfiguration; concurrent regression and race checks pass." +lifecycle-stop-sound,src/cthulhu/sound_theme_manager.py,sound,Play the graceful-shutdown sound cue,Storm Dragon,internal/sound,in-progress,go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow|TestStopTimeoutPreservesShutdownCueForRetry)$' -count=1; go test ./internal/app -run '^(TestRunReachesSoundStopAfterEarlierServiceStopError|TestRunReportsSoundStopError)$' -count=1,Maintainer heard the post-fix stop cue through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11; the launcher returned to the prompt and completed final shutdown records.,,,,"Python plays stop only when enableSound is true and the theme is not none; it interrupts current sound, waits up to 1 second through the helper, then continues shutdown. Go runs stop as the final reverse lifecycle service with its own 3-second deadline, cancels active cues, discards pending cues, and preserves a timed-out stop cue for one retry. audit-required: Python helper-isolation versus Go in-process cancellation and retry ordering are not directly aligned by the named evidence." +focus-events,src/cthulhu/event_manager.py,focus,Receive focus events and capture immutable accessible snapshots with stable object paths,Storm Dragon,internal/atspi,implemented,go test ./internal/atspi ./internal/app ./internal/logging -count=1,GTK yad Zenity and LXTerminal live tests passed 2026-09-10; post-fix AT-SPI shutdown passed through cthulhu.sh with its LXTerminal test application on Xorg 2026-09-11.,,,,Queue overflow duplicate-object suppression and nonblocking diagnostics have automated coverage; physical diagnostics saturation remains pending. +xterm-focus-handoff,src/cthulhu/input_event_manager.py,input,Suspend Cthulhu while XTerm owns focus and restore when a known non-XTerm window takes focus,Storm Dragon,internal/app internal/atspi internal/platform/xorg,in-progress,NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/app -run '^(TestXTermFocus|TestProcessEvents)' -count=1; NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/platform/xorg -count=1; python3 tests/native_x11.py,Private Xvfb/D-Bus proof passes: XTerm focus suspends speech and command grabs; Insert+Q reaches a real XTerm; an unknown active window remains suspended; a known non-XTerm window restores all existing command and release checks.,,Pending human listening with Fenrir in XTerm,,Follow-up gates delegated modifier interruption and echo; commands dispatch on press with balanced shutdown draining. Go retains the existing XTerm identity matching focus monitoring grab suspension and event suppression boundary. Notifications bypass suppression. Unknown active windows remain suspended so an incomplete match cannot strand a delegated terminal reader; live Fenrir handoff remains pending. +focus-speech,src/cthulhu/scripts/default.py,speech,Speak the focused accessible object including common control states and range values,Storm Dragon,internal/atspi internal/presentation internal/app,in-progress,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst.,,Maintainer reported that all combined YAD I38 and LibreOffice tests worked great on 2026-09-12.,,Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Physical listening pending. +terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,in-progress,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11.,,Maintainer heard terminal caret speech during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; broader terminal application and editing coverage remains pending. +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,in-progress,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 accepted terminal insertion, Writer duplicate suppression, and YAD close-and-prompt behavior on physical Xorg.",,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. +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,in-progress,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py,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 accepted I38 run-dialog completion selection speech on physical Xorg on 2026-09-12.,,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. +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,in-progress,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Physical Xorg listening and wrap acceptance pending.,,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. Complete Python structural settings and dialogs remain pending. 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,in-progress,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.,,Physical forum listening acceptance pending.,,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. +web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed document content at the browser caret,Storm Dragon,internal/atspi internal/review internal/app internal/presentation,in-progress,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20.,,Physical Xorg speech and sound listening pending.,,Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. 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 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. +config-read-preview,src/cthulhu/backends/toml_backend.py,settings,Read existing Cthulhu TOML and merge the startup profile,Storm Dragon,internal/config,implemented,go test ./internal/config ./cmd/cthulhu -count=1,,,,,Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work. +config-speech-preview,src/cthulhu/speechdispatcherfactory.py,speech,Apply supported active-profile speech settings,Storm Dragon,internal/config internal/speech internal/app,in-progress,go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1,,,Pending human listening gate,,Applies enablement description punctuation capitalization and default voice settings. +typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,in-progress,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer heard the configured printable echo behavior in minimal tests on 2026-09-12.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. +typing-nonprintable-boundary-echo,src/cthulhu/typing_echo_presenter.py,input,Apply non-printable key and word or sentence echo settings,Storm Dragon,internal/config internal/input internal/platform/xorg internal/atspi internal/presentation internal/app,in-progress,go test ./internal/config ./internal/input ./internal/platform/xorg ./internal/atspi ./internal/presentation ./internal/app -count=1,Pending physical Xorg category chord and pass-through matrix,,Pending human listening for modifier function action navigation dead-key word and sentence echo,,XRecord copies stable non-printable names from the active keymap and suppresses configured Cthulhu modifier chords. Word and sentence echo use bounded text before the caret and retain the prior line across a newline. Printable delivery remains AT-SPI based to avoid duplicate XRecord speech. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. +keyboard-speech-interrupt,src/cthulhu/input_event.py,input,Interrupt speech on non-consuming Control or Shift press,Storm Dragon,internal/input internal/platform/xorg internal/app,in-progress,go test ./internal/input ./internal/platform/xorg ./internal/app -count=1,Maintainer accepted Control and Shift interruption without stolen or stuck keys in the Stage 1 physical Xorg checklist on 2026-09-11.,,Speech interruption was audible during the accepted physical Xorg checklist.,,XRecord observation and speech-worker wiring are implemented; broader X11 matrix remains pending. +where-am-i-basic,src/cthulhu/where_am_i_presenter.py,command,Repeat basic information about the current focused accessible,Storm Dragon,internal/input internal/platform/xorg internal/app internal/presentation,in-progress,go test ./internal/input ./internal/platform/xorg ./internal/app -count=1; python3 tests/native_x11.py,Private X11 proof covers unmodified desktop KP_Enter consumption and balanced replay of desktop Cthulhu+Return; maintainer accepted the corrected physical desktop binding on Xorg on 2026-09-13,,Maintainer confirmed both the basic spoken result and corrected desktop binding on 2026-09-13,,Uses the latest accepted immutable focus or managed-descendant snapshot and the Python no-location message. keyboardLayout selects desktop KP_Enter or laptop Cthulhu+Return. Detailed context repeated-press behavior and selection details remain pending. +owned-object-tree,src/cthulhu/ax_object.py,review,Own stable accessible identities and navigate a validated tree without retaining native objects,Storm Dragon,internal/atspi internal/review internal/app,in-progress,go test ./internal/atspi ./internal/review ./internal/app -count=1; python3 tests/native_atspi.py,Private Xvfb D-Bus and GTK automation captures the desktop invalidates a removed defunct object recaptures into the same owned tree and invalidates references on adapter shutdown.,,,,Identity combines the AT-SPI application bus name and object path. Complete-tree replacement preserves live references; subtree invalidation detaches removed nodes and makes old references stale. Parent child sibling malformed oversized concurrent and structural-event policy cases have unit coverage. The runtime worker follows the latest focus keeps capture off event and command loops recaptures stale state and presents semantic movement or Python boundary messages. Capture waits honor context and individual libatspi calls use explicit 800 ms ordinary and startup timeouts. GtkBox native addition delivery and recapture pass alongside the original ListBox defunct proof. Incomplete child lists trigger one worker recapture preserving live independent review position; failure changed-focus and repeated-invalidation tests pass. Physical dynamic-container recovery native ambiguous removal simplified navigation and text ranges remain pending. Component geometry is recorded separately. Object-navigation key acceptance is recorded separately. +object-navigation,src/cthulhu/object_navigator.py,review,Navigate the owned accessible hierarchy and present each object or boundary,Storm Dragon,internal/review internal/app internal/input internal/platform/xorg,in-progress,go test ./internal/review ./internal/app ./internal/input ./internal/platform/xorg -count=1; python3 tests/native_x11.py,Private X11 proof covers all four Cthulhu Control Arrow bindings with balanced consumption and no application leakage plus Insert-first and Control-first ordering for Up. Maintainer accepted physical Xorg press release behavior with no leaked or stuck keys on 2026-09-14.,,Maintainer heard all expected object and boundary announcements on physical Xorg on 2026-09-14.,,Runtime commands and asynchronous capture presentation are bound to Python's parent first-child previous-sibling and next-sibling gestures. Python simplified navigation filtering action invocation and toggle remain pending. Desktop numpad belongs to flat review and is intentionally not aliased to hierarchical object navigation. +clipboard-present-command,src/cthulhu/plugins/Clipboard/plugin.py,command,Present clipboard text URI objects image presence empty state or access errors with Cthulhu+Shift+C,Storm Dragon,internal/input internal/clipboard internal/app internal/platform/xorg,in-progress,go test ./internal/clipboard ./internal/input ./internal/app ./internal/platform/xorg -count=1; isolated Xvfb clipboard helper and native command test,Private X11 proof covers Cthulhu+Shift+C press dispatch and both release orders; isolated GTK clipboard reads confirmed text URI and image formats,,Pending human speech acceptance for text URI image empty and error cases,,The Go command reads GTK 3 clipboard data in a short lived helper with a three second timeout so a stalled clipboard owner cannot block input or shutdown. It announces text URI object names and types image presence empty state and access errors. URI-only clipboard data is presented even when no text target exists. Physical Xorg listening remains pending. +display-version-command,src/cthulhu/plugins/DisplayVersion/plugin.py,command,Speak the Cthulhu version plus revision AT-SPI version and session details with Cthulhu+Shift+V,Storm Dragon,internal/input internal/presentation internal/version,pending,,Pending Cthulhu+Shift+V X11 key-consumption and release gate,,Pending human listening gate,,Python registers the gesture once and presents only through an active script; the announcement conditionally includes the code name build revision and XDG session type and desktop. The Phase 0 audit also confirmed that plugin.info marks this as a visible non-builtin plugin. Go currently implements command-line version output only; that is not evidence for this spoken command. +game-mode-command,src/cthulhu/plugins/GameMode/plugin.py,input,Toggle reduced speech interruption for ordinary unbound key presses with Cthulhu+Control+G,Storm Dragon,internal/input internal/config internal/presentation,pending,,Pending global Cthulhu+Control+G and unbound-key X11 non-consumption gate,,Pending human listening gate for toggle messages and held-key speech continuity,,Python keeps interruption for handled or consumed commands and Cthulhu modifiers while game mode is enabled and resets the setting on plugin deactivation. The Phase 0 audit also confirmed that plugin.info marks this as a visible non-builtin plugin. The Go preview now interrupts on every observed key press; reduced-interruption game mode remains pending. +hello-world-plugin-example,src/cthulhu/plugins/hello_world/plugin.py,extension,Register a learn-mode-aware Cthulhu+Z example command that presents hello world,Storm Dragon,api/lua/v1 docs/contributor,pending,,Pending extension-command X11 key-consumption and release gate,,Pending human listening and learn-mode acceptance,,Python is a visible non-builtin test plugin and directly accesses the active script without checking that it exists. Preserve the user-facing tutorial behavior in the process-isolated Lua extension examples rather than embedding this Python plugin. +self-voice-socket,src/cthulhu/plugins/self_voice/plugin.py,remote,Accept external UTF-8 text over a local Unix socket for speech and braille presentation with append and persistent markers,Storm Dragon,,accepted-omission,,,,,,Decision 0005 omits XDG_RUNTIME_DIR/cthulhu.sock and its <#APPEND#> and <#PERSISTENT#> marker protocol in favor of the established Cthulhu D-Bus self-voice API. No socket bridge or compatibility parser will be implemented. +self-voice-dbus-present,src/cthulhu/dbus_service.py,remote,Present an external message through normal speech and braille output over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test ./internal/remote ./internal/app -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus and handler tests pass same-user dispatch introspection and output queueing.,,Pending PresentMessage speech listening acceptance,Pending PresentMessage braille acceptance,PresentMessage queues speech without interruption and braille when connected; it returns false only if neither output is available. Physical display delivery remains unverified. +self-voice-dbus-speak,src/cthulhu/dbus_service.py,remote,Speak external plain text with caller-selected interruption over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test ./internal/remote ./internal/app -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus proof passes same-user dispatch introspection conflict shutdown release and reacquisition.,,Pending SpeakText queued and interrupting speech listening acceptance,,Empty and over-64-KiB text is rejected. Noninterrupting text queues; interrupting text clears pending output uses the owned cancellation barrier and then queues the requested text. +self-voice-dbus-braille,src/cthulhu/dbus_service.py,remote,Display an external persistent braille message over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test -tags brlapi ./internal/remote ./internal/app -count=1,Focused tests confirm connected-worker queueing and rejection when disconnected.,,,Pending physical BrailleMessage delivery and persistent-display acceptance,BrailleMessage queues a persistent frame only after the BrlAPI helper reports ready. It rejects empty and over-10-KiB text; physical display and Wine client acceptance remain open. +self-voice-dbus-cancel,src/cthulhu/dbus_service.py,remote,Cancel current speech at an external application's request over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test ./internal/remote ./internal/app -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus proof passes same-user dispatch introspection conflict shutdown release and reacquisition.,,Pending CancelSpeech interruption and subsequent-delivery listening acceptance,,CancelSpeech uses the asynchronous owned speech-worker interruption path and returns false when speech is unavailable. Like Python it does not cancel sound output. +nvda-controller-ssml-rejection,src/cthulhu/dbus_service.py,remote,Reject invalid or unsupported NVDA Controller 2 SSML without claiming mark or completion support,Storm Dragon,internal/remote,implemented,go test ./internal/remote -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus proof passes the real SpeakSsml signature and unsupported result.,,,,Malformed XML or a non-speak root returns Windows ERROR_INVALID_PARAMETER 87. Valid speak-root SSML returns ERROR_NOT_SUPPORTED 50 matching Python because mark callbacks and completion semantics are unavailable. +flat-review-keys,src/cthulhu/flat_review_presenter.py,review,Navigate and present content with desktop and laptop review keys,Storm Dragon,internal/input internal/review internal/app internal/platform/xorg,in-progress,DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/input ./internal/platform/xorg ./internal/presentation -count=1; DISPLAY=:0 python3 tests/native_atspi.py; DISPLAY=:0 python3 tests/native_x11.py,Private Xvfb tests pass all nine desktop and laptop sequential review commands; both laptop release orders; NumLock numeric keypad typing; laptop keypad pass-through; existing command and shutdown checks.,,Maintainer accepted all currently implemented terminal flat-review behavior on physical Xorg on 2026-09-23; broader application listening remains pending,Pending braille reading gate,Basic previous/current/next line word and code-point character commands are wired to owned window layout and the existing review worker. Window boundaries stop silently as in Python. Capture limits are reported explicitly. Native GTK text navigation and edit recovery pass. Viewport seeking mode toggling same-object caret following after leaving review state/value zones advanced bindings mouse review and full application parity remain pending. See docs/testing/flat-review.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. +preferences-basic-speech,src/cthulhu/cthulhu_gui_prefs.py,settings,Open accessible basic speech preferences and apply or save settings,Storm Dragon,internal/preferences internal/app internal/config,in-progress,go test -race ./... -count=1; python3 tests/native_preferences.py; python3 tests/native_x11.py,Private Xvfb GTK and command checks pass; maintainer accepted save shutdown restart and restored settings on physical Xorg on 2026-09-13; broader desktop traversal pending,,Pending human listening for immediate rate pitch and volume previews plus punctuation and capitalization,,Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Default-voice rate pitch volume and server-default toggles preview immediately without committing settings; Cancel restores the last applied voice configuration. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending. +review-component-geometry,src/cthulhu/flat_review.py,review,Capture component screen bounds and visibility for flat-review clipping,Storm Dragon,internal/atspi,implemented,DISPLAY=:0 go test ./internal/atspi -count=1; DISPLAY=:0 python3 tests/native_atspi.py,Private Xvfb D-Bus GtkBox tests verify screen bounds resize invalidation changed dimensions hide invalidation and recapture. Physical acceptance deferred by maintainer on 2026-09-14.,,,,Geometry queries run only during owned-tree capture. Bounds and visibility events invalidate geometry without removing identities. Negative coordinates overflow-safe clipping malformed geometry and concurrent invalidation have unit tests. Component bounds do not establish occlusion text ranges zones reading order or flat-review key support. +review-visible-text,src/cthulhu/flat_review.py,review,Capture bounded visible text with character offsets and screen clipping,Storm Dragon,internal/atspi,in-progress,DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/parity -count=1; DISPLAY=:0 python3 tests/native_atspi.py,Private ListBox and GtkBox capture edit clipping hidden-text and password-exclusion checks pass. Physical acceptance deferred.,,Pending navigation and listening gate,,Exact Unicode offsets and owned ranges now include captured caret offsets. Text caret and geometry events make cached text stale without removing identities. Maximum 4096 characters and 256 lines or ranges per object and 1 MiB per tree. Oversized objects remain limited with no partial text. Basic zones and review commands now consume this data; large-document viewport seeking and physical listening remain pending. +date-time-commands,src/cthulhu/date_and_time_presenter.py,command,Speak local time with Cthulhu+T and date with a double tap using saved formats,Storm Dragon,internal/input internal/app internal/preferences internal/config,in-progress,go test ./... -count=1; focused race checks; native keyboard double-tap checks,Private Xvfb press-time and both-release-order checks passed; physical Xorg pending,,Pending human listening,Pending braille,All 7 Python time and 16 Python date format choices are wired including locale seconds word numeric full and abbreviated variants; see docs/testing/time-date.md. +notifications,src/cthulhu/notification_presenter.py;src/cthulhu/scripts/apps/notification-daemon/script.py;src/cthulhu/scripts/apps/notify-osd/script.py;src/cthulhu/mako_notification_monitor.py,notification,Present provider notifications save history expose mako live actions speak the last notification and open the notification-list GUI,Storm Dragon,internal/notification internal/app internal/notificationui internal/atspi internal/input,in-progress,NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./... -count=1; NO_COLOR=1 python3 tests/native_notifications.py,,,Automated speech-queue history list-state and list-action assertions passed; headless Xvfb/AT-SPI GUI proof passed; human listening and physical Xorg use not verified,Braille not implemented,Go registers window-create and accessible-name events maps notification-daemon mate and xfce4 providers handles notify-osd gauge and name events keeps a 55-entry history and adds Cthulhu+N plus Cthulhu+Ctrl+N. The modal accessible list opens in a GTK helper process with sortable rows copy dismiss clear close dynamic actions and mako live refresh. Live provider Xorg acceptance remains pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. +ordinary-key-speech-interrupt,src/cthulhu/input_event.py,input,Interrupt speech on an ordinary key press even if focus does not move,,internal/input internal/app,implemented,go test ./internal/input ./internal/app -run TestProcessInputEventsInterruptsSpeechOnKeyPress -count=1,,,,,Python KeyboardEvent._present interrupts on press outside game mode. Go now interrupts on every observed press; physical FastSMRW table-boundary listening remains pending. +table-cell-selection-speech,src/cthulhu/speech_generator.py; src/cthulhu/scripts/default.py,speech,Omit selected from table-cell focus speech and avoid deselection chatter during movement,,internal/presentation internal/app,implemented,go test ./internal/presentation ./internal/app -count=1,,,,,Selected table-cell focus omits the state; a focused selected-state event can still announce selected. Physical FastSMRW listening remains pending. +braille-focused-text-routing,src/cthulhu/braille.py,braille,Route display cells to the native focused text caret,,internal/braille internal/app internal/atspi,in-progress,go test -race -tags brlapi ./internal/braille ./internal/app ./internal/atspi -count=1; python3 tests/native_atspi.py,Private GTK/Xvfb routes Unicode and EOL carets and rejects stale content and old focus. Physical Xorg pending.,,,Physical display routing and BRLTTY ownership recovery pending.,Frame IDs and panned Unicode offsets return through the helper; the GLib owner revalidates focus and source text. Component actions and mouse fallback; virtual browser carets; contracted and review routing remain pending. See docs/testing/braille.md. +braille-focused-object-formatting,src/cthulhu/formatting.py;src/cthulhu/braille_generator.py;src/cthulhu/object_properties.py,braille,Format focused text and common controls for braille,,internal/app internal/atspi,in-progress,go test ./internal/app ./internal/atspi ./internal/braille -count=1,Private GTK/Xvfb verifies native Unicode capture and caret offsets.,,,Physical text and control reading pending.,Default verbose long-role subset: control indicators; expansion; values; EOL and text state suffixes. Full ancestor/group/table context; labels distinct from names; masks; translation; preferences and application overrides remain pending. Reference inspected at local Python b9dd2eb2bf96143309ba0c724c47f86d978826ef; the original inventory oracle commit is unavailable in that checkout. See docs/testing/braille.md. +editable-text-selection,src/cthulhu/scripts/default.py;src/cthulhu/script_utilities.py,editing,Speak changed selected or unselected text in a focused editable control,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/input -run 'TestTextSelection|TestIntent' -count=1; CTHULHU_NATIVE_BROWSER_TESTS=selection python3 tests/native_browser.py,Private Brave/Xvfb fixture verified native selection offsets and text in the browser input.,,Physical speech listening pending.,Physical selection dots pending.,The Go event loop compares bounded native selection ranges and excludes password text and autocomplete; browse-mode document selection is tracked separately in web-browse-selection. See docs/testing/editing-selection.md. +editable-cut-paste-feedback,src/cthulhu/scripts/default.py;src/cthulhu/script_utilities.py,editing,Announce cut and paste after editable text mutations,,internal/input internal/app,implemented,go test ./internal/app ./internal/input -run 'TestClipboardFeedback|TestIntent' -count=1; CTHULHU_NATIVE_BROWSER_TESTS=selection python3 tests/native_browser.py,Private Brave/Xvfb fixture verified Ctrl+X deletion and Ctrl+V insertion events.,,Physical cut and paste listening pending.,Physical short-message braille pending.,Go says cut or pasted from clipboard once after a matching focused editable mutation; subsequent paste text and caret events do not interrupt the message. Python uses full or brief clipboard messages. See docs/testing/editing-selection.md. +editable-copy-feedback,src/cthulhu/scripts/default.py,editing,Confirm a successful copy to the clipboard,,internal/input internal/platform/xorg internal/app,in-progress,go test ./internal/input ./internal/app -run 'TestIntent|TestCopyFeedback' -count=1; CTHULHU_NATIVE_X11=1 go test ./internal/platform/xorg -run TestNativeClipboardOwnerChange -count=1,Private Xvfb observed XFixes clipboard ownership change after copy.,,Physical Xorg listening pending.,Short-message braille pending.,Go says copied to clipboard once after Ctrl+C and an X11 clipboard ownership change; it stays silent if ownership does not change. See docs/testing/editing-selection.md. +web-browse-selection,src/cthulhu/scripts/web/script.py;src/cthulhu/script_utilities.py,web,Select document text with Shift and Ctrl+Shift arrows in browse mode,,internal/review internal/atspi internal/app,in-progress,Private Brave modes fixture verifies selected and unselected speech plus braille dots within one page text object and selected speech from a heading into a paragraph.,,,Maintainer reported on 2026-10-03 that browser selection works but a selected message is missing after selecting a section.,,The browser can perform native selection without a Go selection command. Go follows native selection within text objects and across the tested heading-to-paragraph transition; the reported section case is unreproduced. The maintainer checks clipboard contents to confirm selection. See docs/testing/reading-selection-diagnostics.md. +web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md. +web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole,web,Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation,Storm Dragon,internal/atspi internal/review internal/app,in-progress,"go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py",Private Brave/Xorg named-link and key checks passed,,Fixture speech verified; real Steam listening pending,Named-link browse braille uses the full accessible label; physical check pending,docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates +steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging diff --git a/tests/browser-catalog.html b/tests/browser-catalog.html new file mode 100644 index 0000000..13ec048 --- /dev/null +++ b/tests/browser-catalog.html @@ -0,0 +1,26 @@ + +Cthulhu Browser Probe + + +

Video catalog

+
+ + diff --git a/tests/browser-fragments.html b/tests/browser-fragments.html new file mode 100644 index 0000000..4379263 --- /dev/null +++ b/tests/browser-fragments.html @@ -0,0 +1,26 @@ + + + +Cthulhu Browser Probe: fragmented response + + +

Fragmented response

+

One two three.Four five six.

+

Next paragraph.

+

Before ordinary link after.

+

Split heading

+
Code first
+Code second
+

Member rank

+
+

Join Date

+ +

Mixed  text.

+

★

+ + + diff --git a/tests/browser-lines.html b/tests/browser-lines.html new file mode 100644 index 0000000..3f3b278 --- /dev/null +++ b/tests/browser-lines.html @@ -0,0 +1,15 @@ + + + +Cthulhu Browser Probe: logical lines + +

Logical line probe

+ +

What's on your mind?

+
First
+Second
+Code text + + + diff --git a/tests/browser-linked-headings.html b/tests/browser-linked-headings.html new file mode 100644 index 0000000..435c021 --- /dev/null +++ b/tests/browser-linked-headings.html @@ -0,0 +1,14 @@ + +Cthulhu Linked Headings Probe + + +

Inside heading link

+

Inside destination

+

Outside heading link

+

Outside destination

+

#1 Username Example date

+

Post destination

Date destination

+

Ordinary heading

+
Choose: First link and Second link
+

First destination

Second destination

+ diff --git a/tests/browser-named-links.html b/tests/browser-named-links.html new file mode 100644 index 0000000..d2fe5da --- /dev/null +++ b/tests/browser-named-links.html @@ -0,0 +1,12 @@ + +Cthulhu Browser Probe + + + +2 +Ordinary multiline link +Useful image label + +Username 🧻 +

After emoji

+ diff --git a/tests/browser-next.html b/tests/browser-next.html new file mode 100644 index 0000000..730d193 --- /dev/null +++ b/tests/browser-next.html @@ -0,0 +1,7 @@ + +Cthulhu Second Page + +

Fresh page heading

+

Fresh page content.

+Return to browser probe + diff --git a/tests/browser-positions.html b/tests/browser-positions.html new file mode 100644 index 0000000..fa43a8f --- /dev/null +++ b/tests/browser-positions.html @@ -0,0 +1,28 @@ + + + +Cthulhu Browser Probe + +
    +
  • First
  • +
  • Middle
  • +
  • Last
  • +
+
    +
  • Virtual item
  • +
  • Unknown size
  • +
+
+
Nested
+
+
+
First action
+
+
Second action
+
+
+ Choose a value +
First
+
Second
+
+ diff --git a/tests/browser.html b/tests/browser.html new file mode 100644 index 0000000..ab2cdfb --- /dev/null +++ b/tests/browser.html @@ -0,0 +1,45 @@ + +Cthulhu Browser Probe + + +

Browser foundation

+

Reading section

+

Plain document text with Café and 猫.

+
  • Example item
  • Registered: September 2026
+
Section separator
+Example link + + + + +

Link destination

+

Ordinary unfocusable heading

+

Text after the ordinary heading.

+ + + + +
+ + + + + + +
Navigation probe
ItemCount
ApplesFive
Merged total
Last item
+ diff --git a/tests/firefox-baseline.html b/tests/firefox-baseline.html new file mode 100644 index 0000000..eebfef7 --- /dev/null +++ b/tests/firefox-baseline.html @@ -0,0 +1,10 @@ + + + +Cthulhu Firefox Baseline +

Firefox baseline heading

+

Firefox baseline paragraph.

+Firefox baseline link + +

Firefox baseline destination.

+ diff --git a/tests/gtk_alert.py b/tests/gtk_alert.py new file mode 100644 index 0000000..e6aceea --- /dev/null +++ b/tests/gtk_alert.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""GTK message dialog fixture for native alert capture.""" +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, Gtk + + +window = Gtk.Window(title="Cthulhu Alert Probe") +window.connect("destroy", Gtk.main_quit) +window.set_default_size(320, 180) + + +def on_key_press(_window, event): + if event.keyval != Gdk.KEY_u: + return False + dialog = Gtk.MessageDialog( + transient_for=window, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Version 0.6.0 is available. Open the releases page in your browser?", + ) + dialog.set_title("Check for Updates") + dialog.run() + dialog.destroy() + return True + + +window.connect("key-press-event", on_key_press) +window.show_all() +Gtk.main() diff --git a/tests/gtk_progress.py b/tests/gtk_progress.py new file mode 100644 index 0000000..33016ad --- /dev/null +++ b/tests/gtk_progress.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Controlled GTK progress fixture, with keyboard focus on a separate button.""" +import sys +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk + + +window = Gtk.Window(title="Progress probe") +window.set_default_size(400, 200) +box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) +window.add(box) +progress = Gtk.ProgressBar() +progress.get_accessible().set_name("Download") +box.pack_start(progress, False, False, 0) +button = Gtk.Button(label="Keep focus") +box.pack_start(button, False, False, 0) +other = Gtk.Window(title="Other probe") +other.set_default_size(400, 200) +other.move(450, 0) +other.add(Gtk.Entry()) +window.show_all() +other.show_all() +window.present() +button.grab_focus() + + +def command_received(source, condition): + line = sys.stdin.readline().strip() + if not line: + Gtk.main_quit() + return False + if line.startswith("hide "): + progress.hide() + progress.set_fraction(float(line.split()[1])) + elif line == "show": + progress.show() + else: + progress.set_fraction(float(line)) + return True + + +GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command_received) +GLib.idle_add(lambda: print("ready", flush=True)) +Gtk.main() diff --git a/tests/gtk_radio_group.py b/tests/gtk_radio_group.py new file mode 100644 index 0000000..0fbaf17 --- /dev/null +++ b/tests/gtk_radio_group.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Small GTK radio group for native accessibility capture.""" +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +window = Gtk.Window(title="Radio group probe") +window.connect("destroy", Gtk.main_quit) +frame = Gtk.Frame(label="Punctuation Level") +buttons = Gtk.Box(spacing=8) +first = Gtk.RadioButton.new_with_label_from_widget(None, "None") +second = Gtk.RadioButton.new_with_label_from_widget(first, "Some") +buttons.add(first) +buttons.add(second) +frame.add(buttons) +window.add(frame) +window.show_all() +Gtk.main() diff --git a/tests/gtk_selection.py b/tests/gtk_selection.py new file mode 100644 index 0000000..1a3513f --- /dev/null +++ b/tests/gtk_selection.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Small GTK tree-selection fixture for the isolated native X11 test.""" + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk + + +window = Gtk.Window(title="Cthulhu Selection Probe") +window.connect("destroy", Gtk.main_quit) + +store = Gtk.ListStore(str) +store.append(("Alpha result",)) +store.append(("Beta result",)) + +view = Gtk.TreeView(model=store) +view.set_headers_visible(False) +view.append_column(Gtk.TreeViewColumn("Result", Gtk.CellRendererText(), text=0)) +view.get_selection().set_mode(Gtk.SelectionMode.SINGLE) +window.add(view) + + +def select_result(path): + view.get_selection().select_path(path) + view.set_cursor(path) + return False + + +window.show_all() +view.grab_focus() +GLib.timeout_add(500, select_result, 0) +GLib.timeout_add(800, select_result, 1) +GLib.timeout_add(2000, Gtk.main_quit) +Gtk.main() diff --git a/tests/gtk_table.py b/tests/gtk_table.py new file mode 100644 index 0000000..cb351fb --- /dev/null +++ b/tests/gtk_table.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Small GTK table with a window description for native speech checks.""" + +import gi +import os + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +window = Gtk.Window(title="Table presentation probe") +window.connect("destroy", Gtk.main_quit) +window.get_accessible().set_description("Select an item and press Enter to open it.") +store = Gtk.ListStore(str, str) +store.append(["Alpha", "Document"]) +store.append(["Beta", "Shortcut"]) +view = Gtk.TreeView(model=store) +view.get_accessible().set_name("Probe items") +name_column = Gtk.TreeViewColumn("Name") +icon_renderer = Gtk.CellRendererPixbuf() +icon_renderer.set_property("icon-name", "text-x-generic") +name_column.pack_start(icon_renderer, False) +name_renderer = Gtk.CellRendererText() +name_column.pack_start(name_renderer, True) +name_column.add_attribute(name_renderer, "text", 0) +view.append_column(name_column) +kind_renderer = Gtk.CellRendererText() +view.append_column(Gtk.TreeViewColumn("Kind", kind_renderer, text=1)) +multi = os.environ.get("CTHULHU_GTK_TABLE_MULTI") == "1" +if multi: + view.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + button = Gtk.Button(label="Before table") + box.pack_start(button, False, False, 0) + box.pack_start(view, True, True, 0) + window.add(box) +else: + window.add(view) +view.get_selection().select_path(0) +if multi: + view.get_selection().select_path(1) +window.show_all() +if multi: + button.grab_focus() +else: + view.grab_focus() +Gtk.main() diff --git a/tests/gtk_tree.py b/tests/gtk_tree.py new file mode 100644 index 0000000..caeb949 --- /dev/null +++ b/tests/gtk_tree.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Mutable GTK tree fixture for the isolated native AT-SPI test.""" +import os + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, GLib, Gtk, Pango + + +window = Gtk.Window(title="Cthulhu Tree Probe") +window.connect("destroy", Gtk.main_quit) +window.set_default_size(320, 180) +resize_pending = False +activation_window = None +container = ( + Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + if os.environ.get("CTHULHU_TREE_CONTAINER") == "box" + else Gtk.ListBox() +) +container.get_accessible().set_name("Dynamic controls") +window.add(container) + + +def make_row(name): + row = Gtk.ListBoxRow() + row.add(Gtk.Label(label=name)) + return row + + +persistent = make_row("Persistent control") +removable = make_row("Removable control") +container.add(persistent) +container.add(removable) +review_label = Gtk.Label(label="Café 猫\nSecond line") +review_label.get_accessible().set_name("Review text probe") +container.add(review_label) +protected = Gtk.Entry() +protected.set_visibility(False) +protected.set_text("fixture secret") +protected.get_accessible().set_name("Protected text probe") +container.add(protected) +caret_entry = Gtk.Entry() +caret_entry.set_text("Caret 猫 text") +caret_entry.set_position(6) +caret_entry.get_accessible().set_name("Caret review probe") +container.add(caret_entry) +scroller = Gtk.ScrolledWindow() +scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) +scroller.set_size_request(100, 35) +clipped_label = Gtk.Label(label="Clipped " + "猫" * 60 + " OFFSCREEN") +clipped_label.set_xalign(0) +clipped_label.get_accessible().set_name("Clipped review probe") +scroller.add(clipped_label) +container.add(scroller) + +numeric = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, -10, 10, 0.01) +numeric.set_digits(2) +numeric.set_value(1.25) +numeric.get_accessible().set_name("Numeric value probe") +container.add(numeric) + +if os.environ.get("CTHULHU_SPEECH_METADATA") == "1": + mnemonic_button = Gtk.Button.new_with_mnemonic("_Activate") + mnemonic_button.get_accessible().set_name("Mnemonic probe") + container.add(mnemonic_button) + indentation_entry = Gtk.Entry() + indentation_entry.set_text("\t Indented É") + indentation_entry.set_position(5) + indentation_entry.get_accessible().set_name("Indentation probe") + container.add(indentation_entry) + color_view = Gtk.TextView() + color_view.get_accessible().set_name("Color attribute probe") + color_view.set_wrap_mode(Gtk.WrapMode.NONE) + color_buffer = color_view.get_buffer() + color_buffer.set_text("Red example") + red_tag = color_buffer.create_tag("red", foreground="#ff0000", underline=Pango.Underline.ERROR) + color_buffer.apply_tag(red_tag, color_buffer.get_start_iter(), color_buffer.get_iter_at_offset(3)) + color_buffer.place_cursor(color_buffer.get_iter_at_offset(1)) + container.add(color_view) + + +def on_key_press(_window, event): + global activation_window, removable, resize_pending + if os.environ.get("CTHULHU_SPEECH_METADATA") == "1": + if event.keyval == Gdk.KEY_F9: + mnemonic_button.grab_focus() + return True + if event.keyval == Gdk.KEY_F10: + indentation_entry.grab_focus() + return True + if event.keyval == Gdk.KEY_F11: + color_view.grab_focus() + return True + if event.keyval == Gdk.KEY_F6: + caret_entry.grab_focus() + return True + if event.keyval == Gdk.KEY_F7: + protected.grab_focus() + return True + if event.keyval == Gdk.KEY_F8: + numeric.grab_focus() + return True + if event.keyval == Gdk.KEY_Escape: + window.destroy() + return True + if event.keyval == Gdk.KEY_r and removable is not None: + container.remove(removable) + removable = None + window.set_title("Cthulhu Tree Probe Removed") + return True + if event.keyval == Gdk.KEY_v: + review_label.hide() + window.set_title("Cthulhu Tree Probe Text Hidden") + return True + if event.keyval == Gdk.KEY_t: + review_label.set_text("Changed 猫\nNew line") + window.set_title("Cthulhu Tree Probe Text Changed") + return True + if event.keyval == Gdk.KEY_m: + resize_pending = True + window.resize(500, 300) + return True + if event.keyval == Gdk.KEY_h: + persistent.hide() + window.set_title("Cthulhu Tree Probe Hidden") + return True + if event.keyval == Gdk.KEY_a: + container.add(make_row("Added control")) + container.show_all() + window.set_title("Cthulhu Tree Probe Added") + return True + if event.keyval == Gdk.KEY_w: + activation_window = Gtk.Window(title="Cthulhu Activation Probe") + activation_window.set_transient_for(window) + activation_window.set_default_size(240, 100) + activation_button = Gtk.Button(label="Activated control") + activation_window.add(activation_button) + activation_window.show_all() + activation_button.grab_focus() + activation_window.present() + GLib.timeout_add(1000, activation_window.destroy) + return True + return False + + +def on_size_allocate(_window, allocation): + global resize_pending + if resize_pending and allocation.width == 500 and allocation.height == 300: + resize_pending = False + window.set_title("Cthulhu Tree Probe Resized") + + +window.connect("size-allocate", on_size_allocate) +window.connect("key-press-event", on_key_press) +window.show_all() +window.present() +if os.environ.get("CTHULHU_TREE_MANUAL") != "1": + GLib.timeout_add_seconds(20, Gtk.main_quit) +Gtk.main() diff --git a/tests/gtk_web_notification.py b/tests/gtk_web_notification.py new file mode 100644 index 0000000..9e8671a --- /dev/null +++ b/tests/gtk_web_notification.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Controlled AT-SPI toast provider; process names are fixture identities.""" +import ctypes +import gi +import os +import sys + +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 + +# PR_SET_NAME changes only this fixture's identity; no Steam process is used. +libc = ctypes.CDLL(None) +if libc.prctl(15, sys.argv[1].encode(), 0, 0, 0) != 0: + raise RuntimeError("Cannot set fixture process identity") + +window = Gtk.Window(title="Web notification fixture") +box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) +window.add(box) +toast = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) +toast.get_accessible().set_role(Atk.Role.ALERT) +toast.set_no_show_all(True) +username = Gtk.Label(label="Username") +body = Gtk.Label(label="Playing: Example Game") +toast.add(username) +toast.add(body) +box.add(toast) +window.connect("destroy", Gtk.main_quit) +window.show_all() +foreground = Gtk.Window(title="Foreground fixture") +foreground.set_default_size(300, 80) +foreground.move(400, 200) +foreground.add(Gtk.Entry()) +foreground.show_all() + + +pendingInput = "" + + +def receive_command(source: object, condition: object) -> bool: + 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 == "show": + toast.set_no_show_all(False) + toast.show_all() + elif command == "update": + body.set_text("Username is now online") + elif command == "destroy": + toast.destroy() + elif command == "add": + addedToast = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + addedToast.get_accessible().set_role(Atk.Role.ALERT) + addedToast.add(Gtk.Label(label="A new toast")) + box.add(addedToast) + addedToast.show_all() + 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(foreground.get_window()), flush=True) or False) +Gtk.main() diff --git a/tests/native_alert.py b/tests/native_alert.py new file mode 100644 index 0000000..135a9d9 --- /dev/null +++ b/tests/native_alert.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Private Xvfb/AT-SPI proof for GTK alert message capture.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +sys.dont_write_bytecode = True + + +def inside(): + fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_alert.py"))]) + try: + deadline = time.monotonic() + 10 + window = "" + while time.monotonic() < deadline: + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Alert Probe$"], + capture_output=True, text=True, + ) + if result.returncode == 0: + window = result.stdout.splitlines()[0] + break + time.sleep(0.02) + if not window: + raise RuntimeError("GTK alert probe window missing") + environment = dict(os.environ, CTHULHU_ALERT_WINDOW=window) + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeGTKAlertMessage$", "-count=1", "-timeout=20s"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=25, + ) + environment["CTHULHU_NATIVE_YAD"] = "1" + for name in ("TestNativeYADQuestionMessage", "TestNativeYADListMessage", "TestNativeYADPowerListMessage"): + subprocess.run( + ["go", "test", "./internal/atspi", "-run", f"^{name}$", "-count=1", "-timeout=20s"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=25, + ) + # Keep the radio fixture in the same private X11/AT-SPI session. + environment["CTHULHU_NATIVE_RADIO"] = "1" + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeGTKRadioGroupName$", "-count=1", "-timeout=20s"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=25, + ) + finally: + fixture.terminate() + fixture.wait(timeout=3) + + +def main(): + if len(sys.argv) == 2 and sys.argv[1] == "--inside": + inside() + return + with tempfile.TemporaryDirectory(prefix="cthulhu-alert-"): + read_fd, write_fd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "800x600x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), stderr=subprocess.DEVNULL, + ) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError("Xvfb failed to start") + environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11") + environment.pop("DBUS_SESSION_BUS_ADDRESS", None) + environment.pop("AT_SPI_BUS_ADDRESS", None) + environment.pop("WAYLAND_DISPLAY", None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=50, + ) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == "__main__": + main() diff --git a/tests/native_atspi.py b/tests/native_atspi.py new file mode 100644 index 0000000..92edb93 --- /dev/null +++ b/tests/native_atspi.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Isolated native AT-SPI tree capture and invalidation proof.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + + +def wait_for(check, message, timeout=15): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = check() + if value: + return value + time.sleep(0.02) + raise RuntimeError(message) + + +def run_inside(): + if os.environ.get("CTHULHU_MOUSE_REVIEW") == "1": + run_probe("list") + return + if os.environ.get("CTHULHU_SPEECH_METADATA") == "1": + run_probe("list") + return + # Keep the original ListBox defunct proof; GtkBox also forwards additions. + for container in ("list", "box"): + run_probe(container) + run_probe("box", flat=True) + run_probe("box", flat=True, terminal=True) + run_probe("box", flat=True, terminal=True, terminal_mode="large") + run_probe("box", flat=True, terminal=True, terminal_mode="prompt") + run_table_probe() + run_table_probe(multi=True) + if os.environ.get("CTHULHU_TEST_THUNAR") == "1": + run_thunar_probe() + + +def run_thunar_probe(): + with tempfile.TemporaryDirectory(prefix="cthulhu-thunar-") as temporary: + home = Path(temporary) + folder = home / "Files" + folder.mkdir() + for name in ("Alpha", "Beta"): + (folder / name).write_text("Example content\n", encoding="utf-8") + environment = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), + XDG_CACHE_HOME=str(home / ".cache"), XDG_DATA_HOME=str(home / ".local/share")) + fixture = subprocess.Popen(["thunar", "--window", str(folder)], env=environment) + try: + def find_window(): + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--class", "Thunar"], + capture_output=True, text=True, + ) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + + window = wait_for(find_window, "Thunar window missing") + subprocess.run(["xdotool", "windowfocus", window], check=True) + subprocess.run(["xdotool", "key", "ctrl+2"], check=True) + environment.update(CTHULHU_NATIVE_THUNAR="1", GOCACHE="/tmp/cthulhu-cache") + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeThunarNestedFileCellLabel$", "-count=1", "-timeout=20s"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=25, + ) + finally: + if fixture.poll() is None: + fixture.terminate() + fixture.wait(timeout=3) + + +def run_table_probe(multi: bool = False, screen_lock: bool = False) -> None: + fixture_env = dict(os.environ) + if multi: + fixture_env["CTHULHU_GTK_TABLE_MULTI"] = "1" + fixture = subprocess.Popen([sys.executable, str(Path(__file__).with_name("gtk_table.py"))], env=fixture_env) + try: + def find_window(): + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--name", "^Table presentation probe$"], + capture_output=True, text=True, + ) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + + window = wait_for(find_window, "GTK table probe window missing") + subprocess.run(["xdotool", "windowfocus", window], check=True) + environment = dict(os.environ, CTHULHU_NATIVE_GUI_TABLE="1", CTHULHU_GUI_TABLE_WINDOW=window) + if multi: + environment["CTHULHU_NATIVE_GUI_TABLE_MULTI"] = "1" + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + test_name = "^TestNativeGUITablePresentation$" + if screen_lock: + test_name = "^TestNativeGUITableScreenLockRecovery$" + elif multi: + test_name = "^TestNativeGUITableMultiselectionFocus$" + subprocess.run( + ["go", "test", "./internal/atspi", "-run", test_name, "-count=1", "-timeout=20s"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=25, + ) + finally: + if fixture.poll() is None: + fixture.terminate() + fixture.wait(timeout=3) + + +def run_probe(container, flat=False, terminal=False, terminal_mode=""): + environment = dict(os.environ, CTHULHU_TREE_CONTAINER=container) + environment["CTHULHU_VTE_MODE"] = terminal_mode + fixture = subprocess.Popen([ + sys.executable, + str(Path(__file__).with_name("vte_terminal.py" if terminal else "gtk_tree.py")), + ], env=environment) + try: + def find_window(): + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Tree Probe$"], + capture_output=True, + text=True, + ) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + + window = wait_for(find_window, "GTK tree probe window missing") + subprocess.run(["xdotool", "windowfocus", window], check=True) + environment.update(CTHULHU_NATIVE_ATSPI="1", CTHULHU_TREE_WINDOW=window) + if os.environ.get("CTHULHU_MOUSE_REVIEW") == "1": + environment["CTHULHU_NATIVE_MOUSE_REVIEW"] = "1" + if flat: + environment["CTHULHU_NATIVE_FLAT"] = "1" + if terminal: + environment["CTHULHU_NATIVE_TERMINAL_FLAT"] = "1" + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + subprocess.run( + [ + "go", + "test", + "./internal/review" if flat else "./internal/atspi", + "-run", + "^TestNativeMouseReviewPointerAndHitTest$" if os.environ.get("CTHULHU_MOUSE_REVIEW") == "1" else + "^TestNativeSpeechMetadata$" if os.environ.get("CTHULHU_SPEECH_METADATA") == "1" else + "^TestNativeTerminalFlatReview$" if terminal else "^TestNativeFlatReview$" if flat else "^TestNativeTreeCaptureDefunctAndShutdownInvalidation$", + "-count=1", + "-timeout=20s", + ], + cwd=Path(__file__).resolve().parents[1], + env=environment, + check=True, + timeout=25, + ) + finally: + if fixture.poll() is None: + fixture.terminate() + fixture.wait(timeout=3) + + +def main() -> None: + if len(sys.argv) == 2 and sys.argv[1] == "--inside": + run_inside() + return + if len(sys.argv) == 2 and sys.argv[1] == "--inside-screen-lock": + run_table_probe(screen_lock=True) + return + if len(sys.argv) == 2 and sys.argv[1] == "--inside-table": + run_table_probe() + run_table_probe(multi=True) + return + with tempfile.TemporaryDirectory(prefix="cthulhu-atspi-"): + read_fd, write_fd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1920x1080x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), + stderr=subprocess.DEVNULL, + ) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError("Xvfb failed to start") + environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11") + environment.pop("DBUS_SESSION_BUS_ADDRESS", None) + environment.pop("AT_SPI_BUS_ADDRESS", None) + environment.pop("WAYLAND_DISPLAY", None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), + "--inside-screen-lock" if len(sys.argv) == 2 and sys.argv[1] == "--screen-lock" else + "--inside-table" if len(sys.argv) == 2 and sys.argv[1] == "--table-only" else "--inside"], + env=environment, + check=True, + timeout=60, + ) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == "__main__": + main() diff --git a/tests/native_browser.py b/tests/native_browser.py new file mode 100644 index 0000000..1ebdea8 --- /dev/null +++ b/tests/native_browser.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Chromium AT-SPI proof on a private X server, bus, and browser profile.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + + +BROWSER_PROOFS = { + "fragments": ("./internal/review", "^TestNativeBrowserFragmentLines$", 30, 35, "browser-fragments.html"), + "positions": ("./internal/atspi", "^TestNativeBrowserSetPositions$", 25, 30, "browser-positions.html"), + "tabs": ("./internal/app", "^TestNativeBrowserTabs$", 30, 35), + "namedlinks": ("./internal/app", "^TestNativeBrowserNamedLinks$", 25, 30, "browser-named-links.html"), + "linkedheadings": ("./internal/app", "^TestNativeLinkedHeadings$", 45, 50, "browser-linked-headings.html"), + "catalog": ("./internal/atspi", "^TestNativeBrowserCatalogCapture$", 30, 35, "browser-catalog.html"), + "lines": ("./internal/atspi", "^TestNativeBrowserLogicalLines$", 25, 30, "browser-lines.html"), + "tables": ("./internal/app", "^TestNativeBrowserTables$", 35, 40), + "dynamic": ("./internal/app", "^TestNativeBrowserDynamicNavigation$", 40, 45), + "browser": ("./internal/atspi", "^TestNativeBrowser$", 45, 50), + "live": ("./internal/atspi", "^TestNativeBrowserLiveRegion$", 30, 35), + "document": ("./internal/app", "^TestNativeBrowserDocumentCommands$", 45, 50), + "editing": ("./internal/app", "^TestNativeBrowserEditing$", 45, 50), + "selection": ("./internal/app", "^TestNativeBrowserSelection$", 35, 40), + "modes": ("./internal/app", "^TestNativeBrowserModes$", 45, 50), + "keys": ("./internal/platform/xorg", "^TestNativeBrowseKeyTransitions$", 20, 25), +} + + +def selected_proofs(): + selected = {name.strip() for name in os.environ.get( + "CTHULHU_NATIVE_BROWSER_TESTS", ",".join(BROWSER_PROOFS)).split(",") if name.strip()} + unknown = selected - BROWSER_PROOFS.keys() + if not selected or unknown: + raise ValueError("Select browser proofs from: " + ",".join(BROWSER_PROOFS) + + ("; unknown: " + ",".join(sorted(unknown)) if unknown else "")) + return selected + + +def run_inside(): + browser = os.environ.get("CTHULHU_BROWSER") or shutil.which("brave") + if not browser: + raise RuntimeError("Set CTHULHU_BROWSER to a Chromium-family executable") + selected = selected_proofs() + for name, proof in BROWSER_PROOFS.items(): + if name in selected: + print(f"Running browser proof: {name}", flush=True) + # F7 toggles a profile-wide setting. Each proof must start from + # the same browser state whether run alone or after editing. + run_browser_proof(browser, proof) + + +def run_browser_proof(browser, proof): + with tempfile.TemporaryDirectory(prefix="cthulhu-browser-profile-") as profile: + with tempfile.TemporaryFile() as log: + process = subprocess.Popen([ + browser, "--user-data-dir=" + profile, "--no-first-run", + "--no-default-browser-check", "--disable-background-networking", + "--disable-component-update", "--disable-sync", "--password-store=basic", + "--force-renderer-accessibility", "--ozone-platform=x11", + "--new-window", Path(__file__).with_name(proof[4] if len(proof) > 4 else "browser.html").resolve().as_uri(), + ], stdout=log, stderr=log) + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("Browser exited before the probe") + window_name = "Cthulhu Linked Headings Probe" if proof[1] == "^TestNativeLinkedHeadings$" else "Cthulhu Browser Probe" + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--name", window_name], + capture_output=True, text=True) + if result.stdout.strip(): + window = result.stdout.splitlines()[0] + subprocess.run(["xdotool", "windowfocus", window], check=True) + break + time.sleep(0.1) + else: + raise RuntimeError("Browser fixture window missing") + environment = dict(os.environ, CTHULHU_NATIVE_BROWSER="1") + if proof[1] == "^TestNativeLinkedHeadings$": + environment["CTHULHU_NATIVE_LINKED_HEADINGS"] = "1" + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + package, expression, testTimeout, runTimeout = proof[:4] + subprocess.run(["go", "test", package, "-run", expression, + "-count=1", "-v", f"-timeout={testTimeout}s"], + env=environment, check=True, timeout=runTimeout, + cwd=Path(__file__).resolve().parents[1]) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def main(): + selected = selected_proofs() + if len(sys.argv) == 2 and sys.argv[1] == "--inside": + run_inside() + return + with tempfile.TemporaryDirectory(prefix="cthulhu-browser-"): + read_fd, write_fd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "800x600x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), + stderr=subprocess.DEVNULL, + ) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError("Xvfb failed to start") + environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11") + environment.pop("DBUS_SESSION_BUS_ADDRESS", None) + environment.pop("AT_SPI_BUS_ADDRESS", None) + environment.pop("WAYLAND_DISPLAY", None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, + check=True, + # Budget each isolated browser startup, bounded Go run and + # cleanup instead of imposing one 120-second suite limit. + timeout=15 + sum(30 + BROWSER_PROOFS[name][3] for name in selected), + ) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == "__main__": + main() diff --git a/tests/native_clipboard_preferences.py b/tests/native_clipboard_preferences.py new file mode 100644 index 0000000..80f8516 --- /dev/null +++ b/tests/native_clipboard_preferences.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Clipboard bursts and application chooser on a private Xvfb/AT-SPI session.""" +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import threading + +sys.dont_write_bytecode = True +from native_x11 import wait_for + + +def owner(): + import gi + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") + gi.require_version("GdkPixbuf", "2.0") + from gi.repository import Gdk, GdkPixbuf, GLib, Gtk + GLib.set_prgname("example-clipboard-app") + GLib.set_application_name("Example Clipboard Application") + window = Gtk.Window(title="Example Clipboard Owner") + window.add(Gtk.Entry()) + window.show_all() + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + print("ready", flush=True) + + def command(source, condition): + line = source.readline() + if not line: + Gtk.main_quit() + return False + request = json.loads(line) + if "text" in request: + clipboard.set_text(request["text"], -1) + elif request.get("image"): + image = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, 2, 2) + image.fill(0) + clipboard.set_image(image) + elif "burst" in request: + texts = iter(request["burst"]) + + def next_text(): + text = next(texts, None) + if text is None: + print("ready", flush=True) + return False + clipboard.set_text(text, -1) + return True + + GLib.timeout_add(20, next_text) + return True + print("ready", flush=True) + return True + + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command) + Gtk.main() + + +def inside(): + import pyatspi + binary = os.environ["CTHULHU_BINARY"] + children = [] + + def launch(arguments): + process = subprocess.Popen(arguments, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, text=True) + children.append(process) + return process + + def send(process, value): + process.stdin.write(json.dumps(value) + "\n") + process.stdin.flush() + + def walk(node): + yield node + for child in node: + if child is not None: + yield from walk(child) + + def find_window(name): + for application in pyatspi.Registry.getDesktop(0): + for child in application: + if child is not None and child.name == name: + return child + return None + + def key(*keys): + subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True) + + def focus_window(title): + xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + title + "$"], text=True).splitlines()[0] + subprocess.run(["xdotool", "windowfocus", xid], check=True) + + try: + fixture = launch([sys.executable, __file__, "--owner"]) + assert fixture.stdout.readline().strip() == "ready" + wait_for(lambda: find_window("Example Clipboard Owner"), "clipboard fixture missing") + watcher = launch([binary, "--clipboard-watch"]) + replies = queue.Queue() + + def receive(): + for line in watcher.stdout: + replies.put(json.loads(line)) + + threading.Thread(target=receive, daemon=True).start() + assert replies.get(timeout=5)["Ready"] + + def configure(enabled, epoch): + send(watcher, {"Enabled": enabled, "Epoch": epoch}) + response = replies.get(timeout=5) + assert response["Ready"] and response["Epoch"] == epoch, response + + configure(False, 1) + send(fixture, {"text": "Disabled text"}) + assert fixture.stdout.readline().strip() == "ready" + try: + assert False, ("disabled clipboard was read", replies.get(timeout=0.2)) + except queue.Empty: + pass + configure(True, 2) + texts = ["First clipboard", "Second clipboard", "Third clipboard", "Fourth clipboard"] + send(fixture, {"burst": texts}) + assert fixture.stdout.readline().strip() == "ready" + captured = [replies.get(timeout=5) for _ in texts] + assert [value["Text"] for value in captured] == texts, captured + assert all(value["Epoch"] == 2 for value in captured), captured + send(fixture, {"image": True}) + assert fixture.stdout.readline().strip() == "ready" + assert replies.get(timeout=5)["Text"] == "", "image data was read" + uriOwner = launch(["xclip", "-quiet", "-selection", "clipboard", "-target", "text/uri-list", "-in"]) + uriOwner.stdin.write("file:///tmp/example.txt\n") + uriOwner.stdin.close() + assert replies.get(timeout=5)["Text"] == "", "file URI was read" + configure(False, 3) + send(fixture, {"text": "Still disabled"}) + assert fixture.stdout.readline().strip() == "ready" + try: + assert False, ("disabled clipboard was read", replies.get(timeout=0.2)) + except queue.Empty: + pass + watcher.stdin.close() + assert watcher.wait(timeout=5) == 0 + print("PASS: clipboard disabled, four changes 20ms apart in order, image/URI ignored, disable, shutdown", flush=True) + + wine = launch([sys.executable, "-u", str(Path(__file__).with_name("window_title_fixture.py"))]) + json.loads(wine.stdout.readline()) + + def chooser(): + process = launch([binary, "--preferences-ui"]) + send(process, {"ChooseApplication": True}) + window = wait_for(lambda: find_window("Choose Application Preferences"), "application chooser missing") + tableNode = next(node for node in walk(window) if node.name == "Applications" and node.getRole() == pyatspi.ROLE_TABLE) + table = tableNode.queryTable() + names = [table.getAccessibleAt(row, 0).name for row in range(table.nRows)] + assert "game.exe" in names, names + assert "example-clipboard-app" in names, names + assert names.count("game.exe") == 1, names + assert not any(name.lower().startswith("cthulhu") for name in names), names + focus_window("Choose Application Preferences") + assert tableNode.queryComponent().grabFocus() + return process, tableNode, table, names + + process, tableNode, table, names = chooser() + key("Tab") + wait_for(lambda: not any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(tableNode)), "Tab trapped in application list") + key("shift+Tab") + wait_for(lambda: any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(tableNode)), "Shift+Tab did not return to application list") + key("Escape") + assert process.wait(timeout=5) == 0 + assert process.stdout.read() == "", "Escape selected an application" + process, tableNode, table, names = chooser() + row = names.index("game.exe") + assert table.getAccessibleAt(row, 0).queryComponent().grabFocus() + key("Return") + selected = json.loads(process.stdout.readline()) + assert selected["Application"] == "game.exe", selected + send(process, {"Application": "game.exe", "CanSave": True, "Accepted": True, + "Values": {"KeyboardLayout": 1, "CthulhuModifierKeys": ["KP_Insert"], + "TimeFormat": "24-hour", "DateFormat": "numbers-mdy", + "SpeechHistoryLimit": 50, "Punctuation": 1, + "Capitalization": "none"}}) + wait_for(lambda: find_window("Cthulhu Preferences for game.exe"), "chosen application preferences missing") + focus_window("Cthulhu Preferences for game.exe") + key("Escape") + assert process.wait(timeout=5) == 0 + assert process.stdout.read() == "", "Escape submitted preferences" + print("PASS: AT-SPI/X11/Wine application identities, deduplication, Tab/Shift+Tab, Escape cancels, Enter opens selected preferences", flush=True) + finally: + for process in children: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + + +def main(): + if "--owner" in sys.argv: + owner() + return + if "--inside" in sys.argv: + inside() + return + readFd, writeFd = os.pipe() + server = subprocess.Popen(["Xvfb", "-displayfd", str(writeFd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"], pass_fds=(writeFd,), stderr=subprocess.DEVNULL) + os.close(writeFd) + try: + with os.fdopen(readFd) as reader: + display = reader.readline().strip() + assert display, "Xvfb failed" + env = dict(os.environ, DISPLAY=":" + display, GDK_BACKEND="x11", GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + env.pop(name, None) + subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--inside"], env=env, check=True, timeout=75) + finally: + server.terminate() + server.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/tests/native_firefox.py b/tests/native_firefox.py new file mode 100644 index 0000000..2937a46 --- /dev/null +++ b/tests/native_firefox.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Firefox AT-SPI baseline on a private X server, bus, and browser profile.""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + + +ROOT = Path(__file__).resolve().parents[1] +PAGE = Path(__file__).with_name("firefox-baseline.html") + + +def run_inside(): + linked = os.environ.get("CTHULHU_FIREFOX_LINKED") == "1" + page = Path(__file__).with_name("browser-linked-headings.html") if linked else PAGE + browser = os.environ.get("CTHULHU_FIREFOX") or shutil.which("firefox") + if not browser: + raise RuntimeError("Firefox executable not found") + with tempfile.TemporaryDirectory(prefix="cthulhu-firefox-profile-") as profile: + Path(profile, "user.js").write_text( + 'user_pref("datareporting.policy.firstRunURL", "");\n' + 'user_pref("browser.startup.homepage_override.mstone", "ignore");\n', + encoding="utf-8", + ) + with tempfile.TemporaryFile() as log: + environment = dict(os.environ, MOZ_ACCESSIBILITY_ENABLED="1") + process = subprocess.Popen( + [browser, "--no-remote", "--profile", profile, "--new-window", page.resolve().as_uri()], + env=environment, stdout=log, stderr=log, + ) + try: + deadline = time.monotonic() + 25 + while time.monotonic() < deadline: + if process.poll() is not None: + log.seek(0) + raise RuntimeError("Firefox exited before the probe: " + log.read().decode(errors="replace")) + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--name", "Cthulhu Linked Headings Probe" if linked else "Cthulhu Firefox Baseline"], + capture_output=True, text=True, + ) + if result.stdout.strip(): + window = result.stdout.splitlines()[0] + subprocess.run(["xdotool", "windowfocus", window], check=True) + break + time.sleep(0.1) + else: + raise RuntimeError("Firefox fixture window missing") + environment["CTHULHU_NATIVE_FIREFOX"] = "1" + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + if linked: + environment["CTHULHU_NATIVE_LINKED_HEADINGS"] = "1" + subprocess.run( + ["go", "test", "./internal/app", "-run", "^TestNativeLinkedHeadings$", + "-count=1", "-v", "-timeout=45s"], + cwd=ROOT, env=environment, check=True, timeout=50, + ) + return + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeFirefoxBaseline$", + "-count=1", "-v", "-timeout=45s"], + cwd=ROOT, env=environment, check=True, timeout=55, + ) + subprocess.run( + ["go", "test", "./internal/app", "-run", "^TestNativeBrowserTabs$", + "-count=1", "-v", "-timeout=30s"], + cwd=ROOT, env=environment, check=True, timeout=35, + ) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def main(): + if len(sys.argv) == 2 and sys.argv[1] == "--inside": + run_inside() + return + read_fd, write_fd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "800x600x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), stderr=subprocess.DEVNULL, + ) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError("Xvfb failed to start") + environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11") + environment.pop("DBUS_SESSION_BUS_ADDRESS", None) + environment.pop("AT_SPI_BUS_ADDRESS", None) + environment.pop("WAYLAND_DISPLAY", None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=90, + ) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == "__main__": + main() diff --git a/tests/native_internal_ui.py b/tests/native_internal_ui.py new file mode 100644 index 0000000..40a87bd --- /dev/null +++ b/tests/native_internal_ui.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Run actions and editable internal controls on a private X11/AT-SPI desktop.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + + +def run_inside(): + root = Path(__file__).resolve().parents[1] + environment = dict(os.environ, GOCACHE="/tmp/cthulhu-cache", CTHULHU_NATIVE_X11="1", + CTHULHU_NATIVE_BROWSER="1") + probe = subprocess.Popen(["xev", "-name", "Internal UI background", "-event", "keyboard"], + stdout=subprocess.DEVNULL) + try: + deadline = time.monotonic() + 5 + while True: + found = subprocess.run(["xdotool", "search", "--onlyvisible", "--name", "^Internal UI background$"], + capture_output=True, text=True) + if found.returncode == 0: + background = found.stdout.strip().splitlines()[0] + environment["CTHULHU_UI_BACKGROUND"] = background + subprocess.run(["xdotool", "windowfocus", background], check=True) + break + if time.monotonic() >= deadline: + raise RuntimeError("keyboard probe did not open") + time.sleep(0.02) + subprocess.run(["go", "test", "./internal/platform/xorg", "-count=1", "-timeout=60s", + "-run", "^TestNative(InternalUICapturesAndReleases|LearnModeCapturesKeysAndRestoresCommands|CustomBindingsUpdateAndRelease|CommandGrabSuspensionAndRestoration|SecureInputDropsKeysAndRestoresGrabs|BrowseKeyTransitions)$"], + cwd=root, env=environment, check=True) + finally: + probe.terminate() + probe.wait(timeout=5) + with tempfile.TemporaryDirectory(prefix="cthulhu-internal-ui-") as temporary: + marker = Path(temporary) / "state.json" + environment.update(CTHULHU_UI_MARKER=str(marker), CTHULHU_NATIVE_INTERNAL_UI="1") + fixture = subprocess.Popen([sys.executable, __file__, "--fixture"], env=environment) + try: + deadline = time.monotonic() + 10 + window = "" + while time.monotonic() < deadline: + found = subprocess.run(["xdotool", "search", "--onlyvisible", "--name", + "^Internal UI action probe$"], capture_output=True, text=True) + if found.returncode == 0: + window = found.stdout.strip().splitlines()[0] + break + time.sleep(0.02) + if not window: + raise RuntimeError("GTK fixture did not open") + subprocess.run(["xdotool", "windowfocus", "--sync", window], check=True) + subprocess.run(["go", "test", "./internal/app", "-count=1", "-timeout=30s", + "-run", "^TestNative(InternalUIActionsAndEntry|TentacleClipboardTargetsAndTemporaryImage)$"], + cwd=root, env=environment, check=True) + finally: + fixture.terminate() + fixture.wait(timeout=5) + + +def run_fixture(): + import gi + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + window = Gtk.Window(title="Internal UI action probe") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + button = Gtk.Button(label="Run action") + entry = Gtk.Entry() + entry.get_accessible().set_name("Application text") + box.pack_start(button, False, False, 0) + box.pack_start(entry, False, False, 0) + lines = Gtk.TextView() + lines.get_accessible().set_name("Transfer text lines") + lines.get_buffer().set_text("First line\nSecond line") + lines.get_buffer().place_cursor(lines.get_buffer().get_start_iter()) + box.pack_start(lines, False, False, 0) + password = Gtk.Entry() + password.set_visibility(False) + password.set_text("private fixture password") + password.get_accessible().set_name("Protected transfer text") + box.pack_start(password, False, False, 0) + window.add(box) + state = {"clicks": 0, "text": ""} + marker = Path(os.environ["CTHULHU_UI_MARKER"]) + + def save(): + replacement = marker.with_suffix(".tmp") + replacement.write_text(json.dumps(state)) + replacement.replace(marker) + + def clicked(_button): + state["clicks"] += 1 + save() + + def changed(_entry): + state["text"] = entry.get_text() + save() + + button.connect("clicked", clicked) + entry.connect("changed", changed) + window.connect("destroy", Gtk.main_quit) + window.show_all() + button.grab_focus() + save() + Gtk.main() + + +if __name__ == "__main__": + if "--fixture" in sys.argv: + run_fixture() + elif "--inside" in sys.argv: + run_inside() + else: + subprocess.run(["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, + __file__, "--inside"], check=True, + env=dict(os.environ, GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0")) diff --git a/tests/native_notifications.py b/tests/native_notifications.py new file mode 100755 index 0000000..fd5fae9 --- /dev/null +++ b/tests/native_notifications.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Private Xvfb/AT-SPI proof for the Go notification-list helper.""" +import json +import os +from pathlib import Path +import select +import subprocess +import sys + +sys.dont_write_bytecode = True +from native_x11 import wait_for + + +def inside(): + import pyatspi + + binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu")) + process = subprocess.Popen( + [binary, "--notification-list-ui"], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, text=True, + ) + + def send(value): + process.stdin.write(json.dumps(value) + "\n") + process.stdin.flush() + + def request(): + assert select.select([process.stdout], [], [], 5)[0], "no notification-list request" + return json.loads(process.stdout.readline()) + + def walk(node): + yield node + for child in node: + if child is not None: + yield from walk(child) + + def find_window(): + for application in pyatspi.Registry.getDesktop(0): + for node in application: + if node and node.name == "2 notifications": + return node + return None + + def press(node): + assert node.queryComponent().grabFocus(), ("cannot focus", node.name) + subprocess.run(["xdotool", "key", "--clearmodifiers", "space"], check=True) + + def refresh_controls(): + controls.clear() + for node in walk(window): + if node.name: + controls.setdefault(node.name, node) + + def entry(identifier, message, received, can_control=False, actions=None): + return {"id": identifier, "message": message, "received": received, + "canControl": can_control, "actions": actions or []} + + initial = { + "title": "2 notifications", + "selected": "1", + "entries": [ + entry("1", "Live message", "0 seconds ago", True, [ + {"key": "default", "label": "View"}, + {"key": "reply", "label": "Reply"}, + ]), + entry("0", "Older message", "1 minute ago"), + ], + } + try: + send(initial) + window = wait_for(find_window, "notification accessible window missing") + nodes = list(walk(window)) + controls = {} + for node in nodes: + if node.name: + controls.setdefault(node.name, node) + + table = next(node for node in nodes if node.getRole() == pyatspi.ROLE_TABLE) + assert table.name == "2 notifications", table.name + names = {node.name for node in nodes} + for name in ("Live message", "Older message", "0 seconds ago", "1 minute ago", + "Copy", "Dismiss Notification", "Clear", "Close", "View", "Reply"): + assert name in names, ("missing accessible name", name) + interactive_roles = { + pyatspi.ROLE_PUSH_BUTTON, pyatspi.ROLE_TABLE, pyatspi.ROLE_TABLE_CELL, + } + unnamed = sorted(node.getRoleName() for node in nodes + if node.getRole() in interactive_roles and not node.name) + assert not unnamed, ("unnamed interactive controls", unnamed) + assert "Dismiss Notification" in controls + assert controls["Dismiss Notification"].getState().contains(pyatspi.STATE_SENSITIVE) + + xid = subprocess.check_output( + ["xdotool", "search", "--onlyvisible", "--name", "^2 notifications$"], text=True, + ).splitlines()[0] + subprocess.run(["xdotool", "windowfocus", xid], check=True) + assert table.queryComponent().grabFocus(), "cannot focus notification table" + subprocess.run(["xdotool", "key", "--clearmodifiers", "Down"], check=True) + selected = request() + assert selected == {"selection": "0", "row": 1}, selected + send(dict(initial, selected="0")) + + wait_for(lambda: "Notification is no longer available" in { + node.name for node in walk(window) + }, "unavailable action status missing") + assert not controls["Dismiss Notification"].getState().contains(pyatspi.STATE_SENSITIVE) + assert "View" not in {node.name for node in walk(window)} + assert "Reply" not in {node.name for node in walk(window)} + + send(dict(initial, selected="1")) + wait_for(lambda: controls["Dismiss Notification"].getState().contains(pyatspi.STATE_SENSITIVE), + "live selection did not restore controls") + refresh_controls() + press(controls["Reply"]) + invoked = request() + assert invoked == {"action": "reply", "selection": "1", "row": 0}, invoked + send(dict(initial, selected="1")) + refresh_controls() + + press(controls["Copy"]) + copied = request() + assert copied == {"copy": True}, copied + + press(controls["Dismiss Notification"]) + dismissed = request() + assert dismissed == {"dismiss": True, "selection": "1", "row": 0}, dismissed + send({"title": "1 notification", "selected": "0", + "entries": [entry("0", "Older message", "1 minute ago")]}) + refresh_controls() + + press(controls["Clear"]) + cleared = request() + assert cleared == {"clear": True}, cleared + send({"close": True}) + assert process.wait(timeout=5) == 0 + assert process.stdout.read() == "", "clear submitted extra requests" + print("PASS: notification list accessibility, selection refresh, actions, copy, dismiss, clear") + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + +def main(): + if "--inside" in sys.argv: + inside() + return + read_fd, write_fd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), stderr=subprocess.DEVNULL, + ) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + assert display, "Xvfb failed" + environment = dict( + os.environ, DISPLAY=":" + display, GDK_BACKEND="x11", + GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local", + ) + for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(key, None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=60, + ) + finally: + server.terminate() + server.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/tests/native_ownership.py b/tests/native_ownership.py new file mode 100644 index 0000000..ca7eb5a --- /dev/null +++ b/tests/native_ownership.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""GTK/Qt keyboard fixture and isolated X11 foreground acceptance proof.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +sys.dont_write_bytecode = True +from native_x11 import wait_for + + +def fixture(toolkit): + title = "Cthulhu " + toolkit + " Controls" + if toolkit == "GTK": + import gi + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk, Gtk + window = Gtk.Window(title=title) + window.set_default_size(500, 650) + window.connect("destroy", Gtk.main_quit) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + window.add(box) + def add(widget, name): + widget.get_accessible().set_name(name) + box.pack_start(widget, False, False, 0) + return widget + button = add(Gtk.Button(label="Open dialog"), "Open dialog") + def dialog(*args): + child = Gtk.Dialog(title="Ownership dialog", transient_for=window, modal=True) + entry = Gtk.Entry() + entry.get_accessible().set_name("Dialog entry") + child.get_content_area().add(entry) + child.add_button("Close", Gtk.ResponseType.CLOSE) + child.show_all() + child.run() + child.destroy() + button.connect("clicked", dialog) + add(Gtk.CheckButton(label="Updates"), "Updates") + radio = add(Gtk.RadioButton.new_with_label(None, "Automatic"), "Automatic") + add(Gtk.RadioButton.new_with_label_from_widget(radio, "Manual"), "Manual") + add(Gtk.Entry(), "Example text") + combo = add(Gtk.ComboBoxText(), "Choice") + for text in ("Alpha", "Beta", "Gamma"): combo.append_text(text) + combo.set_active(0) + add(Gtk.SpinButton.new_with_range(0, 100, 1), "Amount") + add(Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1), "Level") + for tree, name in ((False, "Results"), (True, "Folders")): + store = Gtk.TreeStore(str) if tree else Gtk.ListStore(str) + if tree: + parent = store.append(None, ["Parent folder"]) + store.append(parent, ["Child folder"]) + else: + for text in ("First result", "Second result"): store.append([text]) + view = add(Gtk.TreeView(model=store), name) + view.append_column(Gtk.TreeViewColumn(name, Gtk.CellRendererText(), text=0)) + view.set_size_request(-1, 80) + disabled = add(Gtk.Button(label="Unavailable action"), "Unavailable action") + disabled.set_sensitive(False) + menu = Gtk.MenuBar() + root = Gtk.MenuItem.new_with_mnemonic("_Actions") + submenu = Gtk.Menu() + submenu.append(Gtk.MenuItem(label="Example action")) + unavailable = Gtk.MenuItem(label="Unavailable menu action") + unavailable.set_sensitive(False) + submenu.append(unavailable) + root.set_submenu(submenu) + menu.append(root) + box.pack_start(menu, False, False, 0) + siblings = [] + def on_key(_window, event): + if event.keyval == Gdk.KEY_F7: + popup = Gtk.Menu() + popup.append(Gtk.MenuItem(label="First link")) + popup.append(Gtk.MenuItem(label="Second link")) + popup.show_all() + popup.popup_at_widget(button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None) + return True + if event.keyval != Gdk.KEY_F6: return False + sibling = Gtk.Window(title="Ownership sibling") + sibling.set_default_size(350, 200) + entry = Gtk.Entry() + entry.get_accessible().set_name("Sibling entry") + sibling.add(entry) + sibling.show_all() + sibling.present() + siblings.append(sibling) + return True + window.connect("key-press-event", on_key) + window.show_all() + button.grab_focus() + Gtk.main() + else: + from PySide6 import QtCore, QtWidgets + app = QtWidgets.QApplication([]) + window = QtWidgets.QWidget() + window.setWindowTitle(title) + layout = QtWidgets.QVBoxLayout(window) + def add(widget, name): + widget.setAccessibleName(name) + layout.addWidget(widget) + return widget + button = add(QtWidgets.QPushButton("Open dialog"), "Open dialog") + def dialog(): + child = QtWidgets.QDialog(window) + child.setWindowTitle("Ownership dialog") + content = QtWidgets.QVBoxLayout(child) + entry = QtWidgets.QLineEdit() + entry.setAccessibleName("Dialog entry") + content.addWidget(entry) + close = QtWidgets.QPushButton("Close") + close.clicked.connect(child.accept) + content.addWidget(close) + child.exec() + button.clicked.connect(dialog) + add(QtWidgets.QCheckBox("Updates"), "Updates") + radio = add(QtWidgets.QRadioButton("Automatic"), "Automatic") + radio.setChecked(True) + add(QtWidgets.QRadioButton("Manual"), "Manual") + add(QtWidgets.QLineEdit(), "Example text") + combo = add(QtWidgets.QComboBox(), "Choice") + combo.addItems(["Alpha", "Beta", "Gamma"]) + add(QtWidgets.QSpinBox(), "Amount") + add(QtWidgets.QSlider(QtCore.Qt.Horizontal), "Level") + view = add(QtWidgets.QListWidget(), "Results") + view.addItems(["First result", "Second result"]) + tree = add(QtWidgets.QTreeWidget(), "Folders") + tree.setHeaderLabels(["Folders"]) + parent = QtWidgets.QTreeWidgetItem(tree, ["Parent folder"]) + QtWidgets.QTreeWidgetItem(parent, ["Child folder"]) + table = add(QtWidgets.QTableWidget(2, 2), "Table") + for row in range(2): + for col in range(2): table.setItem(row, col, QtWidgets.QTableWidgetItem(f"Cell {row+1} {col+1}")) + disabled = add(QtWidgets.QPushButton("Unavailable action"), "Unavailable action") + disabled.setEnabled(False) + menu = QtWidgets.QMenuBar() + root = menu.addMenu("&Actions") + root.addAction("Example action") + root.addAction("Unavailable menu action").setEnabled(False) + layout.addWidget(menu) + window.resize(500, 650) + window.show() + button.setFocus() + app.exec() + + +def inside(directory): + root = Path(directory) + processes = [] + log = root / "cthulhu.log" + def run(*args): + return subprocess.check_output(args, text=True).strip() + def records(): + if not log.exists(): return [] + return [json.loads(line) for line in log.read_text().splitlines() if line.endswith("}")] + def wait_record(message, start=0, **fields): + return wait_for(lambda: next((r for r in records()[start:] if r.get("message") == message and all(r.get(k) == v for k,v in fields.items())), None), message + " " + str(fields)) + def key(*keys): run("xdotool", "key", "--clearmodifiers", *keys) + def activate(xid): run("xdotool", "windowactivate", "--sync", xid) + def find(title): + def search(): + p = subprocess.run(["xdotool", "search", "--onlyvisible", "--name", "^"+title+"$"], capture_output=True, text=True) + return p.stdout.strip().splitlines()[0] if p.stdout.strip() else None + return wait_for(search, "missing " + title) + try: + wm_config = root / "i3.conf" + wm_config.write_text('font pango:monospace 10\nfocus_follows_mouse no\n') + processes.append(subprocess.Popen(["i3", "-c", str(wm_config)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + wait_for(lambda: subprocess.run(["i3-msg", "-t", "get_workspaces"], capture_output=True).returncode == 0, "i3 startup") + settings = root / "settings.toml" + settings.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 1\n') + binary = os.environ["CTHULHU_BINARY"] + reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(reader) + wait_record("listening for focus events") + windows = [] + for toolkit in ("GTK", "Qt"): + initial = len(records()) + processes.append(subprocess.Popen([sys.executable, __file__, "--fixture", toolkit])) + xid = find("Cthulhu " + toolkit + " Controls") + windows.append(xid) + activate(xid) + wait_for(lambda: any(r.get("message") == "queued accessible presentation" and r.get("role") in ("button", "push button") for r in records()[initial:]), "initial button presentation") + start = len(records()) + key("Tab", "space") + wait_record("queued accessible presentation", start, role="check box") + wait_record("queued accessible presentation", start, type="object:state-changed:checked") + # Exercise normal traversal without choosing controls through AT-SPI. + key(*(["Tab"]*12)) + wait_record("queued accessible presentation", start, role="combo box") + wait_record("queued accessible presentation", start, role="spin button") + wait_record("queued accessible presentation", start, role="radio button") + wait_record("queued accessible presentation", start, role="text") + wait_record("queued accessible presentation", start, role="slider") + wait_record("queued accessible presentation", start, role="table cell") + key(*(["shift+Tab"]*12)) + start = len(records()) + key("alt+a", "Down") + wait_for(lambda: any(r.get("message") == "queued accessible presentation" and r.get("role") in ("menu", "menu item") for r in records()[start:]), "menu presentation") + key("Escape", "Escape") + if toolkit == "GTK": + start = len(records()) + key("F7", "Down") + wait_record("queued accessible presentation", start, role="menu item") + key("Escape") + print("PASS:", toolkit, "forward/reverse controls, checked state, and menus") + for xid in windows + windows[::-1]: + start = len(records()) + activate(xid) + key("Tab") + wait_record("queued accessible presentation", start) + # A distinct window in the same process must get its own owner. + activate(windows[0]) + start = len(records()) + key("F6") + sibling = find("Ownership sibling") + wait_record("queued accessible presentation", start, role="text") + owned = wait_record("activated accessible window", start) + expected_window = owned["window_path"] + for xid in (windows[0], sibling, windows[0]): + start = len(records()) + activate(xid) + key("KP_Enter") + wait_record("queued where am I presentation", start) + # Restart with overlapping same-process windows to force recovery to + # distinguish the active sibling from an earlier geometry match. + for xid in (windows[0], sibling): + run("i3-msg", f"[id={xid}] floating enable, border pixel 0, resize set 600 700, move position 100 100") + def geometry(xid): + values = dict(line.split("=", 1) for line in run("xdotool", "getwindowgeometry", "--shell", xid).splitlines()) + return tuple(values[k] for k in ("X", "Y", "WIDTH", "HEIGHT")) + wait_for(lambda: geometry(windows[0]) == geometry(sibling), "identical sibling geometry") + activate(windows[0]) + activate(sibling) + reader.terminate() + assert reader.wait(timeout=5) == 0, "reader restart shutdown failed" + start = len(records()) + reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(reader) + recovered = wait_record("activated accessible window", start) + assert recovered["window_path"] == expected_window, ("recovered inactive same-geometry sibling", recovered, owned) + key("Tab") + wait_record("queued accessible presentation", start, role="text") + print("PASS: recovery selects active same-process, same-geometry sibling") + run("i3-msg", f"[id={sibling}] kill") + # Actual i3 empty workspace, retaining live background applications. + start = len(records()) + run("i3-msg", "workspace 2") + wait_record("X11 foreground ownership changed", start, x_window=0) + key("KP_Enter") + wait_record("queued where am I presentation", start, role="system") + start = len(records()) + run("i3-msg", "workspace 1") + wait_record("queued accessible presentation", start) + # Activate dialog from a predictable keyboard position in a fresh window. + processes.append(subprocess.Popen([sys.executable, __file__, "--fixture", "GTK"])) + new_window = wait_for(lambda: next((w for w in run("xdotool", "search", "--name", "^Cthulhu GTK Controls$").splitlines() if w not in windows), None), "second same-toolkit window") + activate(new_window) + start = len(records()) + key("space") + dialog = find("Ownership dialog") + wait_record("queued accessible presentation", start, role="text") + start = len(records()) + key("Escape") + wait_record("queued accessible presentation", start) + key("KP_Down", "KP_Right", "KP_Left", "Tab") + start = len(records()) + run("i3-msg", f"[id={new_window}] kill") + wait_record("X11 foreground ownership changed", start) + # Brave publishes multiple top-levels on one application bus. Exercise + # the reported browser workflow with both windows at the same bounds. + import shutil + browser = shutil.which("brave") or shutil.which("brave-browser") or shutil.which("chromium") + assert browser, "Chromium browser required for ownership regression" + browser_args = [browser, "--user-data-dir=" + str(root / "browser-profile"), + "--no-first-run", "--no-default-browser-check", + "--disable-background-networking", "--force-renderer-accessibility", + "--ozone-platform=x11", "--new-window"] + start = len(records()) + processes.append(subprocess.Popen(browser_args + [Path(__file__).with_name("browser.html").resolve().as_uri()], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + first_browser = find("Cthulhu Browser Probe.*") + activate(first_browser) + wait_record("activated accessible window", start) + start = len(records()) + subprocess.run(browser_args + [Path(__file__).with_name("browser-next.html").resolve().as_uri()], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + second_browser = find("Cthulhu Second Page.*") + activate(second_browser) + expected_browser = wait_record("activated accessible window", start)["window_path"] + for xid in (first_browser, second_browser): + run("i3-msg", f"[id={xid}] floating enable, border pixel 0, resize set 600 700, move position 100 100") + wait_for(lambda: geometry(first_browser) == geometry(second_browser), "identical browser geometry") + activate(second_browser) + reader.terminate() + assert reader.wait(timeout=5) == 0, "browser recovery restart shutdown failed" + start = len(records()) + reader = subprocess.Popen([binary, "--config", str(settings), "--debug-file", str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(reader) + recovered = wait_record("activated accessible window", start) + assert recovered["window_path"] == expected_browser, ("recovered inactive browser", recovered, expected_browser) + start = len(records()) + key("ctrl+l") + wait_record("queued accessible presentation", start, role="entry") + print("PASS: Brave same-geometry window recovery and address focus without Alt+Tab") + key("Insert+q") + assert reader.wait(timeout=5) == 0, "reader shutdown failed" + print("PASS: window switching, empty workspace/recovery, dialog/destruction, review-key churn, shutdown") + except Exception: + import pyatspi + for application in pyatspi.Registry.getDesktop(0): + def describe(node, depth=0): + if depth > 4: return + try: + print("AX", depth, node.getRoleName(), repr(node.name), "pid", node.get_process_id(), "bounds", node.queryComponent().getExtents(pyatspi.DESKTOP_COORDS), file=sys.stderr) + except Exception as error: print("AX error", error, file=sys.stderr) + for child in node: + if child: describe(child, depth+1) + describe(application) + print(log.read_text() if log.exists() else "No runtime log", file=sys.stderr) + raise + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def main(): + if len(sys.argv) == 3 and sys.argv[1] == "--fixture": + fixture(sys.argv[2]); return + if len(sys.argv) == 3 and sys.argv[1] == "--inside": + inside(sys.argv[2]); return + with tempfile.TemporaryDirectory(prefix="cthulhu-ownership-") as directory: + read_fd, write_fd = os.pipe() + server = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1280x1024x24", "-nolisten", "tcp"], pass_fds=(write_fd,), stderr=subprocess.DEVNULL) + os.close(write_fd) + try: + with os.fdopen(read_fd) as pipe: display = pipe.readline().strip() + assert display, "Xvfb startup failed" + environment = dict(os.environ, DISPLAY=":"+display, XDG_SESSION_TYPE="x11", QT_ACCESSIBILITY="1", QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1") + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "I3SOCK"): + environment.pop(name, None) + subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--inside", directory], env=environment, check=True, timeout=100) + finally: + server.terminate(); server.wait(timeout=3) + + +if __name__ == "__main__": main() diff --git a/tests/native_preferences.py b/tests/native_preferences.py new file mode 100644 index 0000000..1eaf49e --- /dev/null +++ b/tests/native_preferences.py @@ -0,0 +1,1154 @@ +#!/usr/bin/env python3 +"""Private Xvfb/AT-SPI proof for the Go GTK preferences helper.""" +import json +import os +from pathlib import Path +import select +import subprocess +import sys +import tempfile +import time + +sys.dont_write_bytecode = True +from native_x11 import wait_for + + +def inside(): + if os.environ.get("CTHULHU_PREFS_ADAPTER_ONLY") == "1": + environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1") + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + subprocess.run(["go", "test", "./internal/atspi", "-run", + "^TestNativePreferencesDropdownEscape$", "-count=1", "-v", "-timeout=30s"], + cwd=Path(__file__).resolve().parents[1], env=environment, + check=True, timeout=35) + return + import pyatspi + window_manager = None + reader_process = None + private_directory = None + if os.environ.get("CTHULHU_PREFS_HARDWARE_ONLY") == "1" or os.environ.get("CTHULHU_PREFS_APPLY_ONLY") == "1": + private_directory = tempfile.TemporaryDirectory(prefix="cthulhu-prefs-hardware-") + wm_config = Path(private_directory.name) / "i3.config" + wm_socket = Path(private_directory.name) / "i3.sock" + wm_config.write_text(f"font pango:monospace 10\nipc-socket {wm_socket}\n") + window_manager = subprocess.Popen(["i3", "-c", str(wm_config)], + env=dict(os.environ, I3SOCK=str(wm_socket)), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + wait_for(lambda: "0x" in subprocess.check_output(["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private i3 failed to start") + application = os.environ.get("CTHULHU_PREFS_APPLICATION", "") + window_name = "Cthulhu Preferences for " + application if application else "Cthulhu Preferences" + binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu")) + process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, text=True) + initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5, + "SpeechHistoryLimit": 50, + "Reading": {"AutomaticallyReadTitleChanges": True}, + "KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"], + "Punctuation": 1, "Capitalization": "none", + "TimeFormat": "24-hour", "DateFormat": "ymd", + "EnableSound": True, "SoundVolume": 0.5, + "SpeechVerbosityLevel": 1, + "SayAllStyle": "line", + "SoundSink": "auto", "SoundTheme": "default", + "PlaySoundForRole": True, "PlaySoundForState": True, + "PlaySoundForPositionInSet": True, + "SpatializeObjectSounds": False, + "ProgressBar": {"OutputMode": "beep", "ReportBackground": False, + "BeepPercentageInterval": 1, "SpeechPercentageInterval": 10}, + "RoleSoundPresentation": "sound_and_speech", + "Braille": {"Verbosity": 1, "SelectionIndicator": 192, + "LinkIndicator": 192, "FlashMessages": True, + "FlashTime": 5000, "FlashDetailed": True}, + "Pronunciations": [{"Word": "SQL", "Replacement": "sequel"}], + "TextAttributes": {"Indicator": 0, "Choices": [ + {"Key": key, "Speak": True, "Braille": False} + for key in ("size", "family-name", "weight", "indent", "underline", + "strikethrough", "justification", "style", "paragraph-style", + "text-spelling", "fg-color", "bg-color")]}, + "EnableKeyEcho": True, "EnableAlphabeticKeys": True, + "EnableEchoByWord": True, "UseCustomEchoForKey": True}, + "Catalog": {"": [{"Name": "Test voice", "Language": "en", "Variant": "none"}], + "test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"}, + {"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]}, + "CanSave": True, "Accepted": True, "Message": "Preferences test ready."} + if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1": + initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"]) + if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1": + initial["Values"].update(UseCustomEchoVoice=True, + EchoVoice={"Rate": 50, "AveragePitch": 5, "Gain": 5}) + if os.environ.get("CTHULHU_PREFS_LARGE_CATALOG") == "1": + initial["Catalog"]["espeak-ng"].extend( + {"Name": f"Test voice {index}", "Language": "en" if index < 100 else "de", "Variant": "none"} + for index in range(12000)) + initial["Catalog"]["espeak-ng"][-1]["Language"] = "fr" + # Speech Dispatcher also lists its default eSpeak catalog under "". + initial["Catalog"][""] = list(initial["Catalog"]["espeak-ng"]) + initial["Values"].update(UseCustomEchoVoice=True, + EchoVoice={"Family": {"Name": "Spanish test voice", "Lang": "es"}}) + if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1": + initial["Tentacles"] = {"Entries": [], "Suppressed": False} + if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1": + initial["Tentacles"] = {"Entries": [{"Entry": {"ID": "probe", "Name": "Settings probe", "Enabled": True}, + "State": "active", "Shortcuts": ["Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)"]}], "Suppressed": False} + if application: + initial["Application"] = application + def send(value): + process.stdin.write(json.dumps(value) + "\n") + process.stdin.flush() + def send_initial(value): + if os.environ.get("CTHULHU_PREFS_LARGE_CATALOG") != "1": + send(value) + return + # Exercise the helper's bounded catalog protocol with a full catalog + # larger than one scanner line, including a duplicate default module. + assert len(json.dumps(value)) > 1024 * 1024 + metadata = dict(value, Catalog=None, CatalogPending=True) + send(metadata) + for module, voices in value["Catalog"].items(): + for offset in range(0, len(voices), 128): + send({"Catalog": {module: voices[offset:offset + 128]}, "CatalogPending": True}) + send({"CatalogPending": False}) + def walk(node): + yield node + for child in node: + if child is not None: + yield from walk(child) + def find_window(): + for app in pyatspi.Registry.getDesktop(0): + for node in app: + if node and node.name == window_name: + return node + return None + nodes = [] + controls = {} + def named(name): + return controls[name] + def key(*keys): + subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True) + def focus(node): + assert node.queryComponent().grabFocus(), ("cannot focus", node.name) + wait_for(lambda: any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)), "focus missing") + def request(): + assert select.select([process.stdout], [], [], 5)[0], "no Apply request" + return json.loads(process.stdout.readline()) + try: + send_initial(initial) + window = wait_for(find_window, "preferences accessible window missing") + nodes = list(walk(window)) + for node in nodes: + if node.name: + controls.setdefault(node.name, node) + xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0] + subprocess.run(["xdotool", "windowfocus", xid], check=True) + assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences" + if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1": + from native_tentacle_settings import check_page + check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial) + return + if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1": + from native_tentacles import check_page + check_page(pyatspi, process, window, nodes, named, walk, focus, key, + request, send, initial, application) + return + if os.environ.get("CTHULHU_PREFS_OCR_ONLY") == "1": + tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + assert tabs.querySelection().selectChild(8), "cannot select OCR tab" + wait_for(lambda: named("OCR").getState().contains(pyatspi.STATE_SELECTED), "OCR page missing") + language = named("OCR recognition languages") + layout = named("OCR text layout") + scale = named("OCR scale factor") + assert layout.getRole() == pyatspi.ROLE_COMBO_BOX + focus(language); key("Tab", "Tab") + wait_for(lambda: scale.getState().contains(pyatspi.STATE_FOCUSED), + "OCR text layout trapped Tab") + key("shift+Tab", "shift+Tab") + wait_for(lambda: language.getState().contains(pyatspi.STATE_FOCUSED), + "OCR text layout trapped Shift+Tab") + key("Tab", "alt+Down", "Down", "Return") + wait_for(lambda: layout.description == "Single text block", "OCR layout choice did not change") + key("Tab") + wait_for(lambda: scale.getState().contains(pyatspi.STATE_FOCUSED), + "OCR layout popup trapped Tab after selection") + automatic = named("Automatically read the first OCR result") + assert automatic.getRole() == pyatspi.ROLE_CHECK_BOX + assert not automatic.getState().contains(pyatspi.STATE_CHECKED), "automatic reading should default off" + focus(automatic); key("space") + key("Tab") + wait_for(lambda: named("Periodically refresh screen OCR").getState().contains(pyatspi.STATE_FOCUSED), + "Tab did not reach periodic refresh") + key("shift+Tab") + wait_for(lambda: automatic.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to automatic reading") + focus(named("Apply")); key("space") + applied = request() + assert applied["Values"]["OCR"]["AutoReadAll"], applied + assert applied["Values"]["OCR"]["Layout"] == 6, applied + send(dict(initial, Values=applied["Values"])) + wait_for(lambda: automatic.getState().contains(pyatspi.STATE_CHECKED), "applied value was lost") + assert layout.description == "Single text block", "applied OCR layout was lost" + send(initial) + wait_for(lambda: not automatic.getState().contains(pyatspi.STATE_CHECKED), "rollback did not restore automatic reading") + focus(automatic); key("space") + key("Escape") + assert process.wait(timeout=2) == 0, "Escape did not close OCR preferences" + assert process.stdout.read() == "", "cancel submitted changed OCR settings" + print("PASS: OCR layout Tab/Shift+Tab, popup selection, automatic reading checkbox, Apply, rollback, Cancel") + return + if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1": + tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + assert tabs.querySelection().selectChild(6), "cannot select Pronunciation tab" + wait_for(lambda: named("Pronunciation").getState().contains(pyatspi.STATE_SELECTED), "Pronunciation page missing") + dictionary = named("Pronunciation dictionary").queryTable() + assert dictionary.nRows == 1 and dictionary.getAccessibleAt(0, 0).name == "SQL", dictionary.nRows + focus(named("New entry")); key("space") + wait_for(lambda: dictionary.nRows == 2, "New entry did not add a row") + subprocess.run(["xdotool", "type", "--clearmodifiers", "NVDA"], check=True) + key("Return") + focus(dictionary.getAccessibleAt(1, 1)); key("F2") + subprocess.run(["xdotool", "type", "--clearmodifiers", "en vee dee ay"], check=True) + key("Return") + wait_for(lambda: dictionary.getAccessibleAt(1, 0).name == "NVDA" and + dictionary.getAccessibleAt(1, 1).name == "en vee dee ay", "inline pronunciation editing failed") + focus(named("Delete")); key("space") + wait_for(lambda: dictionary.nRows == 1, "Delete did not remove the selected row") + assert tabs.querySelection().selectChild(7), "cannot select Text Attributes tab" + wait_for(lambda: named("Text Attributes").getState().contains(pyatspi.STATE_SELECTED), "Text Attributes page missing") + table = named("Text attributes").queryTable() + assert table.nRows == 12 and table.nColumns == 3, (table.nRows, table.nColumns) + assert table.getAccessibleAt(0, 0).name == "Font size" + focus(table.getAccessibleAt(0, 1)); key("space") + wait_for(lambda: not table.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_CHECKED), "Speak did not toggle") + focus(table.getAccessibleAt(0, 2)); key("space") + wait_for(lambda: table.getAccessibleAt(0, 2).getState().contains(pyatspi.STATE_CHECKED), "Braille did not toggle") + focus(named("Move to bottom")); key("space") + wait_for(lambda: table.getAccessibleAt(11, 0).name == "Font size", "Move to bottom did not reorder") + focus(named("Braille indicator: Dot 7")); key("space") + focus(named("Apply")); key("space") + applied = request() + assert applied["Values"]["Pronunciations"] == initial["Values"]["Pronunciations"], applied + attr = applied["Values"]["TextAttributes"] + assert attr["Indicator"] == 64 and attr["Choices"][-1]["Key"] == "size" and not attr["Choices"][-1]["Speak"] and attr["Choices"][-1]["Braille"], attr + send(dict(initial, Values=applied["Values"], Message="Text preferences saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy") + focus(named("Reset")); key("space") + wait_for(lambda: table.getAccessibleAt(0, 0).name == "Font size", "Reset did not restore order") + focus(named("Cancel")); key("space") + assert process.wait(timeout=2) == 0 + print("PASS: pronunciation and text attribute tables, add/delete, selection, order, indicator, Apply/reload, reset") + return + if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1": + focus(named("Desktop")) + key("ctrl+Page_Down", "ctrl+Page_Down") + wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing") + values = initial["Values"] + def accept_preview(): + preview = request() + assert preview["Preview"], preview + send({"Preview": True, "Accepted": True}) + return preview["Values"] + for name, field in (("Rate", "Rate"), ("Pitch", "Pitch"), ("Volume", "Volume")): + slider = next(n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and n.name.lower().rstrip(":") == name.lower()) + focus(slider) + assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment") + startValue = slider.queryValue().currentValue + for arrow, delta in (("Right", 0.1), ("Up", 1), ("Left", -0.1), ("Down", -1), ("Left", -0.1)): + expected = startValue + delta + key(arrow) + values = accept_preview() + assert abs(slider.queryValue().currentValue - expected) < 1e-8, (name, arrow, expected) + assert abs(values[field] - expected) < 1e-8, (name, arrow, values) + startValue = expected + for bound, arrows in ((0, ("Left", "Down")), + (slider.queryValue().maximumValue, ("Right", "Up"))): + slider.queryValue().currentValue = bound + accept_preview() + key(*arrows) + assert slider.queryValue().currentValue == bound, (name, "boundary", bound) + slider.queryValue().currentValue = startValue + values = accept_preview() + key("Tab") + wait_for(lambda: not slider.getState().contains(pyatspi.STATE_FOCUSED), "Tab trapped on slider") + key("shift+Tab") + wait_for(lambda: slider.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to slider") + focus(named("Use server default rate")) + for page in ("Braille", "Key Echo"): + key("ctrl+Page_Down") + wait_for(lambda: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " page missing") + for name, field in (("Key echo rate", "Rate"), ("Key echo pitch", "AveragePitch"), ("Key echo volume", "Gain")): + slider = named(name) + focus(slider) + assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment") + for arrow, expected in (("Right", 5.1 if field != "Rate" else 50.1), + ("Up", 6.1 if field != "Rate" else 51.1), + ("Left", 6 if field != "Rate" else 51), + ("Down", 5 if field != "Rate" else 50), + ("Left", 4.9 if field != "Rate" else 49.9)): + key(arrow) + wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8, + f"{name}: {arrow} did not produce {expected}") + finalValue = expected + for bound, arrows in ((0, ("Left", "Down")), + (slider.queryValue().maximumValue, ("Right", "Up"))): + slider.queryValue().currentValue = bound + key(*arrows) + assert slider.queryValue().currentValue == bound, (name, "boundary", bound) + slider.queryValue().currentValue = finalValue + focus(named("Apply")); key("space") + applied = request() + assert applied["Save"], applied + for field, expected in (("Rate", 49.9), ("Pitch", 4.9), ("Volume", 4.9)): + assert abs(applied["Values"][field] - expected) < 1e-8, applied + for field, expected in (("Rate", 49.9), ("AveragePitch", 4.9), ("Gain", 4.9)): + assert abs(applied["Values"]["EchoVoice"][field] - expected) < 1e-8, applied + send(dict(initial, Values=applied["Values"], Message="Speech settings saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + for name, expected in (("Key echo rate", 49.9), ("Key echo pitch", 4.9), ("Key echo volume", 4.9)): + assert abs(named(name).queryValue().currentValue - expected) < 1e-8, name + focus(named("Cancel")); key("space") + assert process.wait(timeout=2) == 0 + print("PASS: voice and echo sliders, whole/tenth arrow steps, previews, Tab/Shift+Tab, Apply/reload, Cancel") + return + if os.environ.get("CTHULHU_PREFS_APPLY_ONLY") == "1": + reading_tree = next(node for node in nodes if node.name == "Reading settings" + and node.getRole() == pyatspi.ROLE_TABLE) + reading_table = reading_tree.queryTable() + reader_config = Path(private_directory.name) / "reader.toml" + reader_log = Path(private_directory.name) / "reader.log" + reader_config.write_text('schema-version = 2\nactive-profile = "default"\n' + '[profiles.default.speech]\nenabled = false\n' + '[profiles.default.braille]\nenabled = false\n' + '[profiles.default.sound]\nenabled = false\n') + reader_process = subprocess.Popen([binary, "--config", str(reader_config), + "--debug-file", str(reader_log)], + env=dict(os.environ, NVDA2SPEECHD_HOST="http://127.0.0.1:0"), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + def reader_rows(): + if not reader_log.exists(): + return [] + rows = [] + for line in reader_log.read_text().splitlines(): + try: + rows.append(json.loads(line)) + except ValueError: + pass # The last log write may still be in flight. + return rows + def reader_focused(node, offset): + return any(row.get("message") == "received accessibility event" and + row.get("type") == "object:state-changed:focused" and + row.get("event_detail1") == 1 and row.get("source_path") == node.path + for row in reader_rows()[offset:]) + wait_for(lambda: any(row.get("message") == "listening for focus events" for row in reader_rows()), + "test reader did not start") + focus(named("Help")) + wait_for(lambda: reader_focused(named("Help"), 0), "reader did not observe helper focus") + status = named(initial["Message"]) + for attempt in range(2): + log_offset = len(reader_rows()) + focus(named("Apply")); key("space") + applied = request() + assert applied["Save"] and not applied["Close"], applied + wait_for(lambda: not named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), + "Apply did not enter busy state") + assert not status.getState().contains(pyatspi.STATE_FOCUSED), "Apply moved focus to status" + assert "Applying settings" not in status.name, status.name + started = time.monotonic() + accepted_values = applied["Values"] + if attempt == 1: + accepted_values["Reading"]["SpeakBlankLines"] = True + send(dict(initial, Values=accepted_values, Message="Settings applied and saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), + "Apply stayed busy after success") + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_FOCUSED), + "successful Apply did not restore its button focus") + assert not status.getState().contains(pyatspi.STATE_FOCUSED), "success focused status" + # GTK alone can respond while the reader is still processing + # hundreds of redundant cell events. Require reader-side focus. + focus(named("Cancel")) + wait_for(lambda: reader_focused(named("Cancel"), log_offset), + "reader did not process post-Apply focus", timeout=30) + elapsed = time.monotonic() - started + cell_events = sum(row.get("type") == "object:property-change:accessible-name" and + row.get("role") == "table cell" for row in reader_rows()[log_offset:]) + print(f"Apply {attempt + 1}: {cell_events} table-cell name events; reader focus in {elapsed:.3f}s", flush=True) + assert cell_events <= 32, ("redundant reading-table name events", cell_events) + assert elapsed < 3, ("post-Apply reader focus was delayed", elapsed) + if attempt == 1: + blank_row = next(row for row in range(reading_table.nRows) + if reading_table.getAccessibleAt(row, 1).name == "Speak blank lines") + blank_cell = reading_table.getAccessibleAt(blank_row, 0) + assert blank_cell.name == "checked" and blank_cell.getState().contains(pyatspi.STATE_CHECKED) + focus(named("Apply")); key("space") + rejected = request() + assert rejected["Values"]["Reading"]["SpeakBlankLines"], "backend change did not reach next draft" + send(dict(initial, Accepted=False, Message="Settings were not applied: test rejection.")) + wait_for(lambda: status.getState().contains(pyatspi.STATE_FOCUSED), "rejection did not focus error") + focus(named("OK")); key("space") + saved = request() + assert saved["Save"] and saved["Close"], saved + send(dict(initial, Values=saved["Values"], Close=True, Message="Settings applied and saved.")) + assert process.wait(timeout=5) == 0, "successful OK did not close" + print("PASS: repeated Apply restores button focus, passive success status, focused rejection, OK closes") + return + if application: + focus(named("Desktop")) + key("Right") + wait_for(lambda: named("Laptop").getState().contains(pyatspi.STATE_CHECKED), "application preferences keyboard layout could not be changed") + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + print("PASS: application preferences title, removed profile controls, keyboard focus, Cancel") + return + if os.environ.get("CTHULHU_PREFS_HARDWARE_ONLY") == "1": + focus(named("Desktop")) + for page in ("Sounds", "Speech"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing") + system = named("Speech system") + assert system.querySelection().selectChild(1) + preview = request() + assert preview["Preview"] and preview["Values"]["SpeechServerFactory"] == "hardwarefactory", preview + assert preview["Values"]["SpeechOutputModule"] == "litetalk", preview + send({"Preview": True, "Accepted": True}) + module = named("Speech synthesizer") + wait_for(lambda: any(child.name == "DECtalk" for child in walk(module)), "hardware synthesizers missing") + assert module.querySelection().selectChild(3) + preview = request() + assert preview["Values"]["SpeechOutputModule"] == "dectalk", preview + send({"Preview": True, "Accepted": True}) + device = next(child for child in walk(named("Serial device")) if child.getRole() in (pyatspi.ROLE_ENTRY, pyatspi.ROLE_TEXT)) + assert device.queryEditableText().setTextContents("/dev/ttyTEST0") + preview = request() + assert preview["Values"]["HardwareSpeechDevice"] == "/dev/ttyTEST0", preview + send({"Preview": True, "Accepted": True}) + baud = named("Serial baud rate") + assert baud.querySelection().selectChild(4) + preview = request() + assert preview["Values"]["HardwareSpeechBaudRate"] == 19200, preview + send({"Preview": True, "Accepted": True}) + focus(device) + subprocess.run(["xdotool", "windowfocus", xid], check=True) + key("Tab") + # GTK's internal combo button does not expose STATE_FOCUSED + # reliably. Verify the actual workflow: Tab, change baud with + # Down, and inspect the typed preview request. + key("Down") + preview = request() + assert preview["Values"]["HardwareSpeechBaudRate"] == 38400, preview + send({"Preview": True, "Accepted": True}) + key("shift+Tab") + wait_for(lambda: device.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to serial device") + assert not named("Language").getState().contains(pyatspi.STATE_ENABLED) + assert not named("Person").getState().contains(pyatspi.STATE_ENABLED) + focus(named("Apply")); key("space") + applied = request() + assert applied["Save"] and applied["Values"]["SpeechServerFactory"] == "hardwarefactory", applied + assert applied["Values"]["SpeechOutputModule"] == "dectalk" and applied["Values"]["HardwareSpeechDevice"] == "/dev/ttyTEST0", applied + send({"Values": applied["Values"], "CanSave": True, "Accepted": True, "Message": "Hardware settings applied"}) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "hardware Apply stayed busy") + assert system.querySelection().selectChild(0) + preview = request() + assert preview["Values"]["SpeechServerFactory"] == "speechdispatcherfactory", preview + send({"Preview": True, "Accepted": True}) + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + print("PASS: hardware system/synth/device/baud, Apply, software restore, serial keyboard navigation, Cancel") + return + if os.environ.get("CTHULHU_PREFS_VOICE_TYPES_ONLY") == "1": + focus(named("Desktop")) + for page in ("Sounds", "Speech"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing") + voiceType = named("Voice type") + pitchDefault = named("Use server default pitch") + pitchScale = next(n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and "pitch" in n.name.lower()) + def accept_preview(): + preview = request() + assert preview["Preview"], preview + send({"Preview": True, "Accepted": True}) + return preview["Values"] + for index, kind, pitch in ((1, "uppercase", 8), (2, "hyperlink", 3), (3, "system", 2)): + assert voiceType.querySelection().selectChild(index) + values = accept_preview() + assert values["VoiceType"] == kind and values["Pitch"] == 5, values + assert pitchDefault.getState().contains(pyatspi.STATE_CHECKED) + focus(pitchDefault); key("space") + accept_preview() + pitchScale.queryValue().currentValue = pitch + values = accept_preview() + assert values[kind.capitalize() + "Voice"]["AveragePitch"] == pitch, values + assert voiceType.querySelection().selectChild(0) + values = accept_preview() + assert values["Pitch"] == 5 and not pitchDefault.getState().contains(pyatspi.STATE_CHECKED), values + assert pitchScale.queryValue().currentValue == 5 + for label, mode in (("Beeps", "beeps"), ("Speech and beeps", "both"), ("Speech", "speech")): + radio = next(n for n in nodes if n.getRole() == pyatspi.ROLE_RADIO_BUTTON and n.name == label) + focus(radio); key("space") + focus(named("Apply")); key("space") + applied = request() + assert not applied["Preview"] and applied["Values"]["Reading"]["IndentationPresentationMode"] == mode, applied + values = applied["Values"] + assert values["Pitch"] == 5 and values["UppercaseVoice"]["AveragePitch"] == 8 + assert values["HyperlinkVoice"]["AveragePitch"] == 3 and values["SystemVoice"]["AveragePitch"] == 2 + send(dict(initial, Values=values, Message="Speech settings saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + assert radio.getState().contains(pyatspi.STATE_CHECKED) + assert voiceType.querySelection().selectChild(1) + values = accept_preview() + assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8 + focus(named("Cancel")); key("space") + assert process.wait(timeout=2) == 0 and process.stdout.read() == "" + print("PASS: independent voice editors, inheritance, previews, indentation modes, Apply/reload, Cancel") + return + if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1": + focus(named("Desktop")) + for page in ("Sounds", "Speech"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing") + module = named("Speech synthesizer") + language = named("Language") + person = named("Person") + assert module.querySelection().selectChild(1) + preview = request() + assert preview["Preview"] and preview["Values"]["SpeechOutputModule"] == "espeak-ng", preview + send({"Preview": True, "Accepted": True}) + def person_items(): + return {child.name for child in walk(person) if child.getRole() == pyatspi.ROLE_MENU_ITEM} + wait_for(lambda: "English test voice (en)" in person_items() and + "Spanish test voice (es)" not in person_items(), + "default language did not filter eSpeak voices") + if os.environ.get("CTHULHU_PREFS_LARGE_CATALOG") == "1": + assert len(person_items()) == 102, "other-language catalog voices leaked into Person" + echo_items = {child.name for child in walk(named("Key echo person")) + if child.getRole() == pyatspi.ROLE_MENU_ITEM} + assert len(echo_items) == 103, "default eSpeak catalog flooded the key echo list" + assert named("Key echo person").querySelection().getSelectedChild(0).name == "Spanish test voice (es)", \ + "locale filtering lost the saved foreign-language echo voice" + assert preview["Values"]["EchoVoice"]["Family"]["Name"] == "Spanish test voice", preview + # A language near the end of the old truncated catalog remains + # selectable and its whole list is retained after transport. + assert language.querySelection().selectChild(4) + preview = request() + assert preview["Values"]["SpeechVoiceFamily"]["Lang"] == "fr", preview + send({"Preview": True, "Accepted": True}) + wait_for(lambda: "Test voice 11999 (fr)" in person_items(), + "bounded transfer lost the last French voice") + assert language.querySelection().selectChild(1) + preview = request() + send({"Preview": True, "Accepted": True}) + wait_for(lambda: len(person_items()) == 102, "locale voice list did not recover") + focus(named("Use server default rate")) + subprocess.run(["xdotool", "windowfocus", xid], check=True) + key("Escape") + assert process.wait(timeout=2) == 0, "large catalog blocked Escape" + print("PASS: large voice catalog selection, locale filter, preview, Escape") + return + assert language.querySelection().selectChild(2) + preview = request() + assert preview["Preview"] and preview["Values"]["SpeechVoiceFamily"] == { + "Name": "", "Lang": "es", "Dialect": "", "Variant": ""}, preview + send({"Preview": True, "Accepted": True}) + wait_for(lambda: "Spanish test voice (es)" in person_items() and + "English test voice (en)" not in person_items(), "Spanish language did not filter voices") + assert person.querySelection().selectChild(1) + preview = request() + assert preview["Preview"] and preview["Values"]["SpeechVoiceFamily"]["Name"] == "Spanish test voice", preview + send({"Preview": True, "Accepted": True}) + for index, module_name in ((2, "test-module"), (1, "espeak-ng")): + assert module.querySelection().selectChild(index) + preview = request() + assert preview["Preview"] and preview["Values"]["SpeechOutputModule"] == module_name, preview + send({"Preview": True, "Accepted": True}) + wait_for(lambda: language.description == "es" and + "Spanish test voice (es)" in person_items() and + "English test voice (en)" not in person_items(), + "switching synthesizers lost the current language") + assert language.querySelection().selectChild(1) + preview = request() + assert preview["Preview"] and preview["Values"]["SpeechVoiceFamily"] == { + "Name": "", "Lang": "en", "Dialect": "", "Variant": ""}, preview + send({"Preview": True, "Accepted": True}) + wait_for(lambda: "English test voice (en)" in person_items() and + "Spanish test voice (es)" not in person_items(), "language did not filter voices") + assert person.querySelection().selectChild(1) + preview = request() + assert preview["Values"]["SpeechVoiceFamily"]["Name"] == "English test voice", preview + send({"Preview": True, "Accepted": True}) + focus(named("Brief")); key("space") + wait_for(lambda: named("Brief").getState().contains(pyatspi.STATE_CHECKED), + "brief speech verbosity did not select") + say_all = named("Say All by") + assert say_all.querySelection().selectChild(0) + focus(named("Apply")); key("space") + applied = request() + assert applied["Save"] and applied["Values"]["SpeechOutputModule"] == "espeak-ng", applied + assert applied["Values"]["SpeechVoiceFamily"]["Lang"] == "en", applied + assert applied["Values"]["SpeechVerbosityLevel"] == 0, applied + assert applied["Values"]["SayAllStyle"] == "sentence", applied + send(dict(initial, Values=applied["Values"], Message="Speech settings saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + assert module.description == "espeak-ng" + assert person.querySelection().getSelectedChild(0).name == "English test voice (en)" + assert named("Brief").getState().contains(pyatspi.STATE_CHECKED) + assert say_all.description == "Sentence" + focus(named("Enable speech")); key("Tab", "alt+Down", "Escape") + assert process.poll() is None, "dropdown Escape closed the whole preferences window" + key("Escape") + assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly" + assert process.stdout.read() == "", "Escape submitted changes" + print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape") + return + if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1": + focus(named("Desktop")) + for page in ("Sounds", "Speech"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing") + readingTree = next(node for node in nodes if node.name == "Reading settings" + and node.getRole() == pyatspi.ROLE_TABLE) + readingTable = readingTree.queryTable() + assert readingTable.nColumns == 2 + for row in range(readingTable.nRows): + stateCell = readingTable.getAccessibleAt(row, 0) + settingCell = readingTable.getAccessibleAt(row, 1) + assert settingCell.name and settingCell.name not in ("checked", "unchecked"), settingCell.name + checked = stateCell.getState().contains(pyatspi.STATE_CHECKED) + assert stateCell.name == ("checked" if checked else "unchecked"), stateCell.name + titleRow = next(row for row in range(readingTable.nRows) + if readingTable.getAccessibleAt(row, 1).name == "Automatically read title changes") + titleCell = readingTable.getAccessibleAt(titleRow, 0) + assert titleCell.name == "checked" and titleCell.getState().contains(pyatspi.STATE_CHECKED) + focus(titleCell); key("space") + wait_for(lambda: titleCell.name == "unchecked", "title preference did not toggle") + clipboardRow = next(row for row in range(readingTable.nRows) + if readingTable.getAccessibleAt(row, 1).name == "Automatically read clipboard changes") + clipboardCell = readingTable.getAccessibleAt(clipboardRow, 0) + assert clipboardCell.name == "unchecked" and not clipboardCell.getState().contains(pyatspi.STATE_CHECKED) + focus(clipboardCell); key("space") + wait_for(lambda: clipboardCell.name == "checked", "clipboard preference did not toggle") + for label, expected in (("Announce lists in Say All", "checked"), + ("Present tooltips", "checked"), + ("Speak object under mouse", "checked")): + row = next(row for row in range(readingTable.nRows) + if readingTable.getAccessibleAt(row, 1).name == label) + cell = readingTable.getAccessibleAt(row, 0) + before = cell.name + focus(cell); key("space") + wait_for(lambda cell=cell, expected=expected: cell.name == expected, + f"{label} did not toggle: before={before}, after={cell.name}, row={row}") + focus(readingTree) + key("ctrl+Home", "Left") + stateCell = readingTable.getAccessibleAt(0, 0) + wasChecked = stateCell.getState().contains(pyatspi.STATE_CHECKED) + key("space") + wait_for(lambda: stateCell.name == ("unchecked" if wasChecked else "checked"), "reading checkbox name did not change") + assert stateCell.getState().contains(pyatspi.STATE_CHECKED) != wasChecked + key("Right") + wait_for(lambda: readingTable.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_FOCUSED), "Right did not focus the setting label") + key("Down") + wait_for(lambda: readingTable.isRowSelected(1), "Down did not select the next reading setting") + key("Up") + wait_for(lambda: readingTable.isRowSelected(0), "Up did not return to the first reading setting") + key("Left") + wait_for(lambda: readingTable.getAccessibleAt(0, 0).getState().contains(pyatspi.STATE_FOCUSED), "Left did not focus the checkbox") + key("Tab") + wait_for(lambda: not any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(readingTree)), "Tab did not leave reading settings") + key("shift+Tab") + wait_for(lambda: any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(readingTree)), "Shift+Tab did not return to reading settings") + focus(named("Apply")); key("space") + applied = request() + assert applied["Values"]["Reading"]["OnlySpeakDisplayedText"] != wasChecked, applied + assert not applied["Values"]["Reading"]["AutomaticallyReadTitleChanges"], applied + assert applied["Values"]["Reading"]["AutomaticallyReadClipboardChanges"], applied + assert applied["Values"]["Reading"]["SayAllContextList"], applied + assert applied["Values"]["Reading"]["PresentTooltips"], applied + assert applied["Values"]["Reading"]["EnableMouseReview"], applied + send(dict(initial, Values=applied["Values"], Message="Reading settings saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + assert readingTable.getAccessibleAt(0, 0).name == ("unchecked" if wasChecked else "checked") + assert readingTable.getAccessibleAt(titleRow, 0).name == "unchecked" + assert readingTable.getAccessibleAt(clipboardRow, 0).name == "checked" + assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows) + if readingTable.getAccessibleAt(row, 1).name == "Announce lists in Say All"), 0).name == "checked" + assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows) + if readingTable.getAccessibleAt(row, 1).name == "Present tooltips"), 0).name == "checked" + assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows) + if readingTable.getAccessibleAt(row, 1).name == "Speak object under mouse"), 0).name == "checked" + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + assert process.stdout.read() == "", "Cancel submitted reading changes" + print("PASS: reading table, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel") + return + if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1": + modifierTree = named("Cthulhu modifier keys") + modifierTable = modifierTree.queryTable() + assert modifierTable.nRows == 3 and modifierTable.nColumns == 2 + def assert_modifiers(expected): + for row, label in enumerate(("Insert", "Numpad Insert", "Caps Lock")): + assert modifierTable.getAccessibleAt(row, 1).name == label + cell = modifierTable.getAccessibleAt(row, 0) + checked = label in expected + assert cell.name == ("checked" if checked else "unchecked"), cell.name + assert cell.getState().contains(pyatspi.STATE_CHECKED) == checked, cell.name + def open_bindings(): + focus(named("Desktop")) + for page in ("Sounds", "Speech", "Braille", "Key Echo", "Key Bindings"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing") + def apply_modifiers(layout, keys): + focus(named("Apply")); key("space") + applied = request() + assert applied["Values"]["KeyboardLayout"] == layout, applied + assert applied["Values"]["CthulhuModifierKeys"] == keys, applied + send(dict(initial, Values=applied["Values"], Message="Modifier keys saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + # Existing custom settings survive opening; switching General layout + # chooses defaults, and the binding editor layout stays independent. + assert_modifiers({"Insert", "Numpad Insert"}) + focus(named("Laptop")); key("space") + wait_for(lambda: named("Laptop").getState().contains(pyatspi.STATE_CHECKED), "Laptop not selected") + assert_modifiers({"Caps Lock"}) + open_bindings() + focus(modifierTree); key("ctrl+Home", "space", "Down", "space") + assert_modifiers({"Insert", "Numpad Insert", "Caps Lock"}) + key("Tab") + wait_for(lambda: named("Desktop bindings").getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave modifier table") + key("shift+Tab") + wait_for(lambda: any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(modifierTree)), "Shift+Tab did not return to modifier table") + apply_modifiers(2, ["Insert", "KP_Insert", "Caps_Lock"]) + assert_modifiers({"Insert", "Numpad Insert", "Caps Lock"}) + # Every checkbox can be cleared; Apply/reload preserves an empty list. + focus(modifierTree); key("ctrl+Home", "space", "Down", "space", "Down", "space") + assert_modifiers(set()) + apply_modifiers(2, []) + assert_modifiers(set()) + focus(named("Desktop")); key("space") + wait_for(lambda: named("Desktop").getState().contains(pyatspi.STATE_CHECKED), "Desktop not selected") + assert_modifiers({"Numpad Insert"}) + apply_modifiers(1, ["KP_Insert"]) + assert_modifiers({"Numpad Insert"}) + focus(modifierTree); key("ctrl+Home", "space") + assert_modifiers({"Insert", "Numpad Insert"}) + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + assert process.stdout.read() == "", "Cancel submitted modifier changes" + print("PASS: modifier checkbox table, accessible states, Space, Tab/Shift+Tab, desktop/laptop defaults, multiple/empty selections, Apply/reload, Cancel") + return + if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1": + focus(named("Desktop")) + for page in ("Sounds", "Speech", "Braille", "Key Echo", "Key Bindings"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing") + tree = named("Key bindings") + table = tree.queryTable() + def select_command(label, scope): + for row in range(table.nRows): + if table.getAccessibleAt(row, 0).name == label and table.getAccessibleAt(row, 1).name == scope: + focus(tree) + key("ctrl+Home") + if row: + subprocess.run(["xdotool", "key", "--repeat", str(row), "--delay", "1", "Down"], check=True) + return + raise AssertionError((label, scope)) + status = named(initial["Message"]) + editor = next(node for node in nodes if node.name == "Shortcuts" and node.getRole() == pyatspi.ROLE_TEXT) + def set_binding(text): + editor.queryEditableText().setTextContents(text) + focus(named("Set binding")); key("space") + wait_for(lambda: status.getState().contains(pyatspi.STATE_FOCUSED), "binding status not focused") + select_command("Preferences", "Global") + focus(editor); key("Tab") + wait_for(lambda: named("Set binding").getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave editor") + key("shift+Tab") + wait_for(lambda: editor.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to editor") + set_binding("Cthulhu+q") + wait_for(lambda: "conflicts" in status.name, "conflict not reported") + set_binding("Cthulhu+Ctrl+F8") + wait_for(lambda: "Binding updated" in status.name, "binding not set") + focus(named("Apply")); key("space") + saved = request() + overrides = saved["Values"]["KeyBindings"] + assert len(overrides) == 1 and overrides[0]["Command"] == "preferences" and overrides[0]["Keys"] == ["Cthulhu+Ctrl+F8"], saved + send(dict(initial, Values=saved["Values"], Message="Bindings saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + select_command("Preferences", "Global") + assert editor.queryText().getText(0, -1) == "Cthulhu+Ctrl+F8" + focus(named("Clear binding")); key("space") + focus(named("Apply")); key("space") + cleared = request() + assert cleared["Values"]["KeyBindings"][0]["Keys"] == [], cleared + send(dict(initial, Values=cleared["Values"], Message="Binding cleared.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Clear busy") + select_command("Preferences", "Global") + assert editor.queryText().getText(0, -1) == "" + focus(named("Restore default binding")); key("space") + focus(named("Apply")); key("space") + reset = request() + assert not reset["Values"]["KeyBindings"], reset + send(dict(initial, Values=reset["Values"], Message="Default restored.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Reset busy") + focus(named("Laptop bindings")); key("space") + wait_for(lambda: named("Laptop bindings").getState().contains(pyatspi.STATE_CHECKED), "laptop layout not selected") + select_command("Document next heading", "Browse mode") + set_binding("F6") + focus(named("Apply")); key("space") + laptop = request() + assert laptop["Values"]["KeyBindings"][0]["Layout"] == 2, laptop + send(dict(initial, Values=laptop["Values"], Message="Laptop saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Laptop busy") + focus(named("Laptop bindings")); key("space") + wait_for(lambda: named("Laptop bindings").getState().contains(pyatspi.STATE_CHECKED), "laptop layout not selected") + focus(named("Restore all bindings for this layout")); key("space") + focus(named("Apply")); key("space") + reset_all = request() + assert not reset_all["Values"]["KeyBindings"], reset_all + send(dict(initial, Values=reset_all["Values"], Message="All restored.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Reset all busy") + select_command("Preferences", "Global") + set_binding("F9") + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + assert process.stdout.read() == "", "Cancel submitted bindings" + print("PASS: accessible binding editor, Tab/Shift+Tab, conflict, save/reload, clear, reset, laptop browse, reset all, Cancel") + return + if os.environ.get("CTHULHU_PREFS_BRAILLE_ONLY") == "1": + focus(named("Desktop")) + for page in ("Sounds", "Speech", "Braille"): + key("ctrl+Page_Down") + wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), + page + " page did not open") + combo = named("Contraction table") + assert combo.getRole() == pyatspi.ROLE_COMBO_BOX, combo.getRoleName() + focus(named("Enable contracted braille")) + key("Tab") + key("Tab") + wait_for(lambda: named("Enable word wrap").getState().contains(pyatspi.STATE_FOCUSED), + "Tab did not leave the contraction table") + key("shift+Tab") + key("shift+Tab") + wait_for(lambda: named("Enable contracted braille").getState().contains(pyatspi.STATE_FOCUSED), + "Shift+Tab did not leave the contraction table backwards") + focus(named("Abbreviated role names")); key("space") + focus(named("Disable end of line symbol")); key("space") + focus(named("Enable word wrap")); key("space") + focus(named("Messages are persistent")); key("space") + focus(named("Messages are detailed")); key("space") + braille_brief = [n for n in nodes if n.name == "Brief" and + n.getRole() == pyatspi.ROLE_RADIO_BUTTON][1] + focus(braille_brief); key("space") + focus(named("Selection indicator: Dot 7")); key("space") + focus(named("Link indicator: None")); key("space") + named("Flash message duration").queryValue().currentValue = 3 + selection = combo.querySelection() + assert selection.selectChild(1), "no locale contraction tables" + wait_for(lambda: "en-ueb" in combo.description.lower(), + "US UEB table missing from contraction choices") + focus(named("Apply")); key("space") + applied = request() + braille_values = applied["Values"]["Braille"] + assert braille_values["AbbreviateRoles"] and braille_values["DisableEOL"], applied + assert braille_values["WordWrap"] and braille_values["FlashPersistent"], applied + assert not braille_values["FlashDetailed"] and braille_values["FlashTime"] == 3000, applied + assert braille_values["Verbosity"] == 0 and braille_values["SelectionIndicator"] == 64, applied + assert braille_values["LinkIndicator"] == 0, applied + assert applied["Values"]["BrailleContractionTable"].endswith("en-ueb-g1.ctb"), applied + print("PASS: Braille table chooser and preference protocol") + return + expected_tabs = {"General", "Sounds", "Speech", "Braille", "Key Echo", + "Key Bindings", "Pronunciation", "Text Attributes", "OCR"} + if not application: + expected_tabs.add("Tentacles") + main_tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + tabs = {main_tabs.getChildAtIndex(index).name for index in range(main_tabs.childCount) + if main_tabs.getChildAtIndex(index).getRole() == pyatspi.ROLE_PAGE_TAB} + assert tabs == expected_tabs, ("preferences tabs", tabs) + interactive_roles = { + pyatspi.ROLE_CHECK_BOX, pyatspi.ROLE_COMBO_BOX, pyatspi.ROLE_ENTRY, + pyatspi.ROLE_PAGE_TAB, pyatspi.ROLE_PUSH_BUTTON, pyatspi.ROLE_RADIO_BUTTON, + pyatspi.ROLE_SLIDER, pyatspi.ROLE_SPIN_BUTTON, pyatspi.ROLE_TABLE, + pyatspi.ROLE_TOGGLE_BUTTON, pyatspi.ROLE_TREE_TABLE, + } + unnamed = sorted(n.getRoleName() for n in nodes + if n.getRole() in interactive_roles and not n.name) + assert not unnamed, ("unnamed interactive controls", unnamed) + sliders = [n for n in nodes if n.getRole() == pyatspi.ROLE_SLIDER and + any(word in n.name for word in ("Rate", "Pitch", "Volume"))] + assert len(sliders) == 3, [(n.name, n.getRoleName()) for n in nodes] + desktop = named("Desktop") + assert desktop.getRole() == pyatspi.ROLE_RADIO_BUTTON + assert desktop.getState().contains(pyatspi.STATE_CHECKED), "Desktop radio value missing" + time_format = next(n for n in nodes if n.name == "Time format" and + n.getRole() == pyatspi.ROLE_COMBO_BOX) + time_selection = time_format.querySelection() + assert time_selection.nSelectedChildren == 1 + selected_time = time_selection.getSelectedChild(0).name + assert time_format.description == selected_time, ( + "combo value missing", time_format.description, selected_time) + capitalization = next(n for n in nodes if n.name == "Capitalization style" and + n.getRole() == pyatspi.ROLE_COMBO_BOX) + capitalization_selection = capitalization.querySelection() + assert capitalization.description == capitalization_selection.getSelectedChild(0).name + date_format = next(n for n in nodes if n.name == "Date format" and + n.getRole() == pyatspi.ROLE_COMBO_BOX) + changed = dict(initial, Values=dict(initial["Values"], Capitalization="spell", TimeFormat="12-hour", DateFormat="dmy")) + send(changed) + wait_for(lambda: capitalization.description == "Spell", + "changed combo value was not exposed") + assert capitalization.querySelection().getSelectedChild(0).name == "Spell" + wait_for(lambda: time_format.description == time_selection.getSelectedChild(0).name, + "time format did not initialize") + assert date_format.description == date_format.querySelection().getSelectedChild(0).name + send(initial) + wait_for(lambda: capitalization.description == "None", + "restored combo value was not exposed") + progress = named("Progress bar output") + assert progress.description == "Beep", progress.description + background = named("Report background progress bars") + focus(background) + key("shift+Tab", "Down") + wait_for(lambda: progress.description == "Speak", "progress mode keyboard change missing") + key("Tab") + background = named("Report background progress bars") + wait_for(lambda: background.getState().contains(pyatspi.STATE_FOCUSED), + "progress mode trapped Tab") + key("space") + assert background.getState().contains(pyatspi.STATE_CHECKED) + for old_name in ("Beep progress bar updates", "Progress bar updates apply to", "Play sound for value", + "Progress bar beep frequency in seconds", "Progress bar update frequency in seconds", + "Speak progress bar updates", "Braille progress bar updates"): + assert old_name not in controls, "obsolete progress control remains: " + old_name + assert time_selection.selectChild(1) + assert date_format.querySelection().selectChild(2) + wait_for(lambda: time_format.description == time_selection.getSelectedChild(0).name, + "edited time selection missing") + wait_for(lambda: date_format.description == date_format.querySelection().getSelectedChild(0).name, + "edited date selection missing") + focus(desktop) + key("ctrl+Page_Down") + wait_for(lambda: named("Sounds").getState().contains(pyatspi.STATE_SELECTED), + "Sounds page did not open") + for name in ("Enable sounds", "Play sound for role", "Play sound for state", + "Play sound for position in set"): + assert named(name).getState().contains(pyatspi.STATE_CHECKED), name + " default missing" + assert not named("Use stereo position for object sounds").getState().contains( + pyatspi.STATE_CHECKED), "stereo sound placement was enabled by default" + presentation = named("Element presentation") + presentation_selection = presentation.querySelection() + assert presentation_selection.nSelectedChildren == 1 + for index, expected in enumerate(("Sound and speech", "Speech only", "Sound only")): + assert presentation_selection.selectChild(index) + wait_for(lambda expected=expected: presentation.description == expected, + expected + " presentation did not select") + assert presentation_selection.selectChild(0) + wait_for(lambda: presentation.description == "Sound and speech", "mixed presentation did not restore") + spatialize = named("Use stereo position for object sounds") + focus(spatialize); key("space") + wait_for(lambda: spatialize.getState().contains(pyatspi.STATE_CHECKED), + "stereo sound placement did not toggle") + sound_volume = named("Sound volume") + assert sound_volume.queryValue().minimumValue == 0 + assert sound_volume.queryValue().maximumValue == 1 + master_sound = named("Enable sounds") + focus(master_sound); key("space") + wait_for(lambda: not sound_volume.getState().contains(pyatspi.STATE_SENSITIVE), + "sound master did not disable sound controls") + focus(master_sound); key("space") + key("ctrl+Page_Down") + wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), + "Speech page did not open") + history_limit = named("Speech history entries") + assert history_limit.getRole() == pyatspi.ROLE_SPIN_BUTTON + assert history_limit.queryValue().minimumValue == 1 + assert history_limit.queryValue().maximumValue == 1000 + assert history_limit.queryValue().currentValue == 50 + focus(history_limit) + history_limit.queryValue().currentValue = 75 + for slider in sliders: + relations = slider.getRelationSet() + assert any(r.getRelationType() == pyatspi.RELATION_LABELLED_BY for r in relations), ( + "missing label buddy", slider.name) + assert slider.name, "unnamed slider" + rate = next(n for n in sliders if "Rate" in n.name) + for name in ("Help", "Apply", "Cancel", "OK"): + assert named(name).getRole() == pyatspi.ROLE_PUSH_BUTTON + # Prove the supported controls accept focus, and that the scale does + # not trap forward or reverse keyboard traversal. + for name in ("Use server default rate", "Use server default pitch", + "Use server default volume", "Apply", "Cancel", "OK"): + focus(named(name)) + for slider in sliders: + focus(slider) + focus(named("Use server default rate")) + key("Tab") + wait_for(lambda: rate.getState().contains(pyatspi.STATE_FOCUSED), + "Tab did not enter the rate slider") + key("shift+Tab") + wait_for(lambda: named("Use server default rate").getState().contains( + pyatspi.STATE_FOCUSED), "Shift+Tab did not leave the rate slider") + focus(named("Some")); key("space") + wait_for(lambda: named("Some").getState().contains(pyatspi.STATE_CHECKED), + "radio activation did not select punctuation") + focus(named("Use server default pitch")); key("space") + wait_for(lambda: not next(n for n in sliders if "Pitch" in n.name).getState().contains( + pyatspi.STATE_SENSITIVE), "default pitch did not disable field") + preview = request() + assert preview["Preview"] and preview["Values"]["Pitch"] is None, preview + send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."}) + print("PASS: initial preferences accessibility and keyboard navigation", flush=True) + focus(rate) + rate.queryValue().currentValue = 72 + preview = request() + assert preview["Preview"] and preview["Values"]["Rate"] == 72, preview + send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."}) + focus(named("Apply")) + key("space") + draft = request() + assert draft["Values"]["SpeechHistoryLimit"] == 75, draft + assert draft["Values"]["TimeFormat"] == "12-hour" and draft["Values"]["DateFormat"] == "numbers-md", draft + assert draft["Values"]["Rate"] == 72 and draft["Save"] and not draft["Close"], draft + assert draft["Values"]["Punctuation"] == 2 and draft["Values"]["Pitch"] is None, draft + assert draft["Values"]["EnableSound"] and draft["Values"]["PlaySoundForRole"], draft + assert draft["Values"]["PlaySoundForState"] and draft["Values"]["SpatializeObjectSounds"], draft + assert draft["Values"]["ProgressBar"]["OutputMode"] == "speak", draft + assert draft["Values"]["ProgressBar"]["ReportBackground"], draft + expectedProgress = dict(initial["Values"]["ProgressBar"], OutputMode="speak", ReportBackground=True) + assert draft["Values"]["ProgressBar"] == expectedProgress, draft + # Simulated backend failure: restore editing, expose error by focus, + # and preserve the draft for correction. + rejected = dict(initial, Accepted=False, Message="Settings were not applied: test rejection.") + send(rejected) + status = controls[initial["Message"]] + wait_for(lambda: status.name == rejected["Message"], "error status missing") + wait_for(lambda: status.getState().contains(pyatspi.STATE_FOCUSED), "error status was not focused") + assert rate.queryValue().currentValue == 72, "rejection discarded draft" + focus(named("OK")); key("space") + saved = request() + assert saved["Save"] and saved["Close"] and saved["Values"]["Rate"] == 72 + assert saved["Values"]["ProgressBar"] == expectedProgress, saved + # Reload a backend response with custom TOML-only thresholds. Later + # Apply and OK requests must preserve those too. + expectedProgress = dict(expectedProgress, BeepPercentageInterval=3, SpeechPercentageInterval=17) + saved["Values"]["ProgressBar"] = expectedProgress + accepted = dict(initial, Values=saved["Values"], Message="Settings applied and saved.") + send(accepted) + wait_for(lambda: status.name == accepted["Message"], "success status missing") + print("PASS: Apply, rejected draft, OK protocol", flush=True) + # GtkScale consumes Ctrl+Page_Down as a value change. Verify that + # behavior, then navigate the notebook from the adjacent checkbox. + focus(rate) + previous_rate = rate.queryValue().currentValue + key("ctrl+Page_Down") + wait_for(lambda: rate.queryValue().currentValue != previous_rate, + "slider did not handle Ctrl+Page_Down") + assert named("Speech").getState().contains(pyatspi.STATE_SELECTED) + preview = request() + assert preview["Preview"], preview + send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."}) + rate.queryValue().currentValue = previous_rate + preview = request() + assert preview["Preview"] and preview["Values"]["Rate"] == previous_rate, preview + send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."}) + focus(named("Use server default rate")) + for page in ("Braille", "Key Echo"): + key("ctrl+Page_Down") + wait_for(lambda: named(page).getState().contains(pyatspi.STATE_SELECTED), + page + " page did not open") + # Verify keyboard access, master sensitivity, and echo protocol values. + master = named("Enable key echo") + alphabetic = named("Enable alphabetic keys") + assert master.getState().contains(pyatspi.STATE_CHECKED) + assert alphabetic.getState().contains(pyatspi.STATE_CHECKED) + focus(master); key("space") + wait_for(lambda: not alphabetic.getState().contains(pyatspi.STATE_SENSITIVE), "master did not disable categories") + focus(named("Apply")); key("space") + off = request() + assert off["Save"] and not off["Close"] and not off["Values"]["EnableKeyEcho"], off + assert off["Values"]["ProgressBar"] == expectedProgress, off + assert off["Values"]["EnableAlphabeticKeys"] and off["Values"]["EnableEchoByWord"], off + send(dict(initial, Values=off["Values"], Message="Echo off saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy") + assert not master.getState().contains(pyatspi.STATE_CHECKED) + wait_for(lambda: master.getState().contains(pyatspi.STATE_SENSITIVE), "key echo master stayed insensitive") + focus(master); key("space") + wait_for(lambda: master.getState().contains(pyatspi.STATE_CHECKED), "key echo master did not re-enable") + focus(named("Use custom echo voice")); key("space") + echo_rate = named("Key echo rate") + wait_for(lambda: echo_rate.getState().contains(pyatspi.STATE_SENSITIVE), "custom voice controls disabled") + focus(echo_rate); echo_rate.queryValue().currentValue = 83 + focus(named("Key echo pitch")); named("Key echo pitch").queryValue().currentValue = 7.5 + focus(named("Key echo volume")); named("Key echo volume").queryValue().currentValue = 10 + family = named("Key echo person") + family.querySelection().selectChild(1) + word_voice = named("Use custom voice for word echo") + assert not word_voice.getState().contains(pyatspi.STATE_CHECKED) + focus(word_voice); key("space") + wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED), + "custom word voice did not become checked") + preview = request() + assert preview["Preview"], preview + send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."}) + focus(named("OK")); key("space") + echo_saved = request() + ev = echo_saved["Values"] + assert ev["ProgressBar"] == expectedProgress, ev + assert echo_saved["Save"] and echo_saved["Close"] and ev["EnableKeyEcho"], echo_saved + assert ev["UseCustomEchoVoice"] and ev["UseCustomEchoForWord"], ev + assert ev["EchoVoice"]["Rate"] == 83 and ev["EchoVoice"]["AveragePitch"] == 7.5 and ev["EchoVoice"]["Gain"] == 10, ev + assert ev["EchoVoice"]["Family"]["Name"] == "Test voice", ev + send(dict(initial, Values=ev, Message="Echo voice saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "OK stayed busy") + # Present must retain edits. Escape closes promptly while the parent + # restores the live voice preview after helper exit. + rate.queryValue().currentValue = 61 + preview = request() + assert preview["Preview"] and preview["Values"]["Rate"] == 61, preview + send({"Preview": True, "Accepted": True, "Message": "Speech settings previewed."}) + send({"Present": True}) + focus(master) + key("ctrl+Page_Up", "ctrl+Page_Up") + wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), + "Speech page did not reopen") + focus(named("Use server default rate")) + subprocess.run(["xdotool", "windowfocus", xid], check=True) + key("Escape") + assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly" + assert process.stdout.read() == "", "Escape submitted extra settings" + print("PASS: GTK notebook, roles, label buddies, Tab/Shift+Tab, Apply, failed apply, OK, Escape") + finally: + if reader_process is not None: + reader_process.terminate() + try: + reader_process.wait(timeout=5) + except subprocess.TimeoutExpired: + reader_process.kill() + reader_process.wait(timeout=5) + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if window_manager is not None: + window_manager.terminate() + window_manager.wait(timeout=5) + if private_directory is not None: + private_directory.cleanup() + + +def main(): + if "--inside" in sys.argv: + inside() + return + read_fd, write_fd = os.pipe() + server = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"], pass_fds=(write_fd,), stderr=subprocess.DEVNULL) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + assert display, "Xvfb failed" + env = dict(os.environ, DISPLAY=":" + display, GDK_BACKEND="x11", GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") + if env.get("CTHULHU_PREFS_SPEECH_ONLY") == "1": + env["LC_ALL"] = "en_US.UTF-8" + for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + env.pop(key, None) + subprocess.run(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=env, check=True, timeout=90) + finally: + server.terminate() + server.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/tests/native_progress.py b/tests/native_progress.py new file mode 100644 index 0000000..e029569 --- /dev/null +++ b/tests/native_progress.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Private X11/AT-SPI proof for foreground and background progress output.""" +import os +from pathlib import Path +import subprocess +import sys + + +def main(): + if len(sys.argv) == 2 and sys.argv[1] == "--inside": + environment = dict(os.environ, CTHULHU_NATIVE_PROGRESS="1") + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + subprocess.run(["go", "test", "./internal/app", "-run", "^TestNativeProgressBars$", + "-count=1", "-v", "-timeout=30s"], + cwd=Path(__file__).resolve().parents[1], env=environment, + check=True, timeout=35) + return + readFd, writeFd = os.pipe() + server = subprocess.Popen(["Xvfb", "-displayfd", str(writeFd), "-screen", "0", + "1024x768x24", "-nolisten", "tcp"], + pass_fds=(writeFd,), stderr=subprocess.DEVNULL) + os.close(writeFd) + try: + with os.fdopen(readFd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError("Xvfb failed") + environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11", + GDK_BACKEND="x11", GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(name, None) + subprocess.run(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), + "--inside"], env=environment, check=True, timeout=45) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == "__main__": + main() diff --git a/tests/native_speech_history.py b/tests/native_speech_history.py new file mode 100644 index 0000000..8c38521 --- /dev/null +++ b/tests/native_speech_history.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Private Xvfb/AT-SPI proof for the speech history and monitor helper.""" +import json +import os +from pathlib import Path +import select +import subprocess +import sys + +sys.dont_write_bytecode = True +from native_x11 import wait_for + + +def inside(): + import pyatspi + + binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu")) + + def walk(node): + yield node + for child in node: + if child is not None: + yield from walk(child) + + def window_named(title): + for application in pyatspi.Registry.getDesktop(0): + for node in application: + if node and node.name == title: + return node + return None + + def open_helper(mode, items=None): + process = subprocess.Popen( + [binary, "--speech-history-ui"], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, text=True, + ) + send(process, {"mode": mode, "items": items or []}) + return process + + def send(process, value): + process.stdin.write(json.dumps(value) + "\n") + process.stdin.flush() + + def request(process): + assert select.select([process.stdout], [], [], 5)[0], "no speech history response" + return json.loads(process.stdout.readline()) + + def click(node): + assert node.queryComponent().grabFocus(), ("cannot focus", node.name) + subprocess.run(["xdotool", "key", "--clearmodifiers", "space"], check=True) + + history = open_helper("history", ["Newest speech", "Older speech", "Another line"]) + try: + window = wait_for(lambda: window_named("Speech History - Cthulhu"), "history window missing") + controls = {node.name: node for node in walk(window) if node.name} + for name in ("Filter:", "Newest speech", "Older speech", "Another line", + "Copy to clipboard", "Remove from history", "Close"): + assert name in controls, ("missing history control", name, list(controls)) + table = next(node for node in walk(window) if node.getRole() == pyatspi.ROLE_TABLE) + assert table.queryComponent().grabFocus(), "cannot focus history table" + subprocess.run(["xdotool", "key", "--clearmodifiers", "Down"], check=True) + wait_for(lambda: table.querySelection().getSelectedChild(0).name == "Older speech", + "Down did not select the next history entry") + subprocess.run(["xdotool", "key", "--clearmodifiers", "Up"], check=True) + wait_for(lambda: table.querySelection().getSelectedChild(0).name == "Newest speech", + "Up did not restore the first history entry") + subprocess.run(["xdotool", "key", "--clearmodifiers", "o"], check=True) + wait_for(lambda: table.queryTable().nRows == 1, "typing did not filter by prefix") + subprocess.run(["xdotool", "key", "--clearmodifiers", "BackSpace"], check=True) + wait_for(lambda: table.queryTable().nRows == 3, "backspace did not clear the filter") + assert table.queryComponent().grabFocus(), "cannot refocus history table" + click(controls["Copy to clipboard"]) + assert request(history) == {"action": "copy"} + copied = subprocess.check_output(["xclip", "-selection", "clipboard", "-o"], text=True) + assert copied == "Newest speech", copied + click(controls["Remove from history"]) + removed = request(history) + assert removed == {"action": "remove", "selection": "Newest speech"}, removed + send(history, {"items": ["Older speech", "Another line"]}) + wait_for(lambda: table.queryTable().nRows == 2, "removal did not refresh the list") + click(controls["Remove from history"]) + assert request(history)["selection"] == "Older speech" + send(history, {"items": ["Another line"]}) + wait_for(lambda: table.queryTable().nRows == 1, "second removal did not refresh the list") + click(controls["Remove from history"]) + assert request(history)["selection"] == "Another line" + send(history, {"items": []}) + wait_for(lambda: table.queryTable().nRows == 0, "removing the final item left a row") + click(controls["Close"]) + assert history.wait(timeout=5) == 0 + finally: + if history.poll() is None: + history.kill() + history.wait(timeout=5) + + monitor = open_helper("monitor") + try: + window = wait_for(lambda: window_named("Speech Monitor - Cthulhu"), "monitor window missing") + def monitor_text(): + for node in walk(window): + if node.name == "Speech monitor output": + return node.queryText().getText(0, -1) + return "" + send(monitor, {"append": "Live utterance"}) + wait_for(lambda: "Live utterance" in monitor_text(), "monitor text missing") + controls = {node.name: node for node in walk(window) if node.name} + click(controls["Clear"]) + wait_for(lambda: "Live utterance" not in monitor_text(), "monitor text was not cleared") + click(controls["Close"]) + assert monitor.wait(timeout=5) == 0 + finally: + if monitor.poll() is None: + monitor.kill() + monitor.wait(timeout=5) + print("PASS: speech history accessibility, copy, remove, and live monitor") + + +def main(): + if "--inside" in sys.argv: + inside() + return + read_fd, write_fd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), stderr=subprocess.DEVNULL, + ) + os.close(write_fd) + try: + with os.fdopen(read_fd) as reader: + display = reader.readline().strip() + assert display, "Xvfb failed" + environment = dict(os.environ, DISPLAY=":" + display, GDK_BACKEND="x11", + GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") + for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(key, None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=60, + ) + finally: + server.terminate() + server.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/tests/native_tentacle_settings.py b/tests/native_tentacle_settings.py new file mode 100644 index 0000000..190bd0d --- /dev/null +++ b/tests/native_tentacle_settings.py @@ -0,0 +1,47 @@ +"""Focused private GTK/AT-SPI checks for Tentacle settings and shortcut labels.""" +from native_x11 import wait_for + + +def check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial): + tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + index = next(i for i, n in enumerate(tabs) if n.name == "Tentacles") + assert tabs.querySelection().selectChild(index) + wait_for(lambda: tabs[index].getState().contains(pyatspi.STATE_SELECTED), "Tentacles page missing") + tree = next(n for n in nodes if n.name == "Tentacles" and + n.getRole() in (pyatspi.ROLE_TABLE, pyatspi.ROLE_TREE_TABLE)) + focus(tree) + key("Home") + shortcut = "Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)" + wait_for(lambda: any(n.name == shortcut for n in walk(tree)), "acknowledged shortcut missing") + settings = named("Settings") + status = named(initial["Message"]) + wait_for(lambda: settings.getState().contains(pyatspi.STATE_SENSITIVE), "Settings is disabled") + focus(settings) + key("Tab") + wait_for(lambda: named("Retry").getState().contains(pyatspi.STATE_FOCUSED), "Tab trapped after Settings") + key("shift+Tab") + wait_for(lambda: settings.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to Settings") + key("Return") + sent = request() + assert sent["Tentacle"]["Action"] == "settings" and sent["Tentacle"]["ID"] == "probe", sent + send({"Accepted": False, "Message": "No settings for this tentacle."}) + wait_for(lambda: status.name == "No settings for this tentacle." and + status.getState().contains(pyatspi.STATE_FOCUSED), + "no-settings status missing") + focus(settings) + key("Return") + assert request()["Tentacle"]["Action"] == "settings" + send({"Accepted": True}) + wait_for(lambda: settings.getState().contains(pyatspi.STATE_FOCUSED), "settings response moved focus") + # A following refresh response must retain the selected tentacle and controls. + focus(named("Refresh status")) + key("Return") + assert request()["Tentacle"]["Action"] == "list" + send({"Accepted": True, "Tentacles": initial["Tentacles"], "Message": "Tentacle status."}) + wait_for(lambda: status.name == "Tentacle status.", "refresh response missing") + focus(settings) + key("Return") + assert request()["Tentacle"]["ID"] == "probe" + send({"Accepted": False, "Message": "No settings for this tentacle."}) + wait_for(lambda: status.name == "No settings for this tentacle.", "status missing") + print("PASS: Settings request, no-settings status, shortcut label, Tab/Shift+Tab and selected-row retention", flush=True) diff --git a/tests/native_webscripts.py b/tests/native_webscripts.py new file mode 100644 index 0000000..811bb89 --- /dev/null +++ b/tests/native_webscripts.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Verify notification metadata on a private X server and accessibility bus.""" +import os +from pathlib import Path +import subprocess +import sys + + +def main() -> None: + if sys.argv[1:] == ["--inside"]: + environment = dict(os.environ, CTHULHU_NATIVE_WEBSCRIPTS="1") + environment.setdefault("GOCACHE", "/tmp/cthulhu-cache") + subprocess.run( + ["go", "test", "./internal/atspi", "-run", "^TestNativeWebScriptNotificationCapture$", + "-count=1", "-v", "-timeout=30s"], + cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=35, + ) + return + readFd, writeFd = os.pipe() + server = subprocess.Popen( + ["Xvfb", "-displayfd", str(writeFd), "-screen", "0", "800x600x24", "-nolisten", "tcp"], + pass_fds=(writeFd,), stderr=subprocess.DEVNULL, + ) + os.close(writeFd) + try: + with os.fdopen(readFd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError("Xvfb failed to start") + environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11") + for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(key, None) + subprocess.run( + ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=45, + ) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == "__main__": + main() diff --git a/tests/native_x11.py b/tests/native_x11.py new file mode 100644 index 0000000..07fef38 --- /dev/null +++ b/tests/native_x11.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Isolated X11 command consumption and pass-through proof; never uses desktop DISPLAY.""" +import json +import os +import ctypes +from pathlib import Path +import subprocess +import sys +import tempfile +import time + + +def wait_for(check, message, timeout=15): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = check() + if value: + return value + time.sleep(0.02) + raise RuntimeError(message) + + +def count_records(path, *needles): + return sum(all(needle in line for needle in needles) for line in path.read_text().splitlines()) + + +def press_physical_key(symbol): + # xdotool's keysym synthesis can toggle NumLock between press and release. + # XTest the physical keycode without synthesizing any modifier changes. + x11 = ctypes.CDLL('libX11.so.6') + xtst = ctypes.CDLL('libXtst.so.6') + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + x11.XKeysymToKeycode.restype = ctypes.c_ubyte + x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + xtst.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong] + display = x11.XOpenDisplay(None) + if not display: + raise RuntimeError('private X11 display unavailable') + try: + code = x11.XKeysymToKeycode(display, symbol) + assert code, 'physical key missing from private keymap' + assert xtst.XTestFakeKeyEvent(display, code, 1, 0) + assert xtst.XTestFakeKeyEvent(display, code, 0, 0) + x11.XSync(display, 0) + finally: + x11.XCloseDisplay(display) + + +def set_active_window(window_id): + x11 = ctypes.CDLL('libX11.so.6') + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] + x11.XDefaultRootWindow.restype = ctypes.c_ulong + x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int] + x11.XInternAtom.restype = ctypes.c_ulong + x11.XChangeProperty.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_int, + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int, + ] + x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + display = x11.XOpenDisplay(None) + if not display: + raise RuntimeError('private X11 display unavailable') + try: + root = x11.XDefaultRootWindow(display) + property_atom = x11.XInternAtom(display, b'_NET_ACTIVE_WINDOW', False) + window_atom = x11.XInternAtom(display, b'WINDOW', False) + active_window = ctypes.c_ulong(window_id) + x11.XChangeProperty( + display, + root, + property_atom, + window_atom, + 32, + 0, + ctypes.byref(active_window), + 1, + ) + x11.XSync(display, 0) + finally: + x11.XCloseDisplay(display) + + +def set_window_class(window_id, instance_name, class_name): + x11 = ctypes.CDLL('libX11.so.6') + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] + x11.XDefaultRootWindow.restype = ctypes.c_ulong + x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int] + x11.XInternAtom.restype = ctypes.c_ulong + x11.XChangeProperty.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_int, + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int, + ] + x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + display = x11.XOpenDisplay(None) + if not display: + raise RuntimeError('private X11 display unavailable') + try: + window = ctypes.c_ulong(window_id) + property_atom = x11.XInternAtom(display, b'WM_CLASS', False) + string_atom = x11.XInternAtom(display, b'STRING', False) + class_data = ctypes.create_string_buffer(f'{instance_name}\0{class_name}\0'.encode()) + x11.XChangeProperty( + display, + window, + property_atom, + string_atom, + 8, + 0, + class_data, + len(class_data) - 1, + ) + x11.XSync(display, 0) + finally: + x11.XCloseDisplay(display) + + +def check_speech_mode_shortcut(log, events, modifier): + for modifier_first in (True, False): + before_s = events.read_text().count(', s)') + before_command = count_records(log, '"message":"keyboard command received"', + '"command":"cycle-speech-mode"') + subprocess.run(['xdotool', 'keydown', modifier, 'keydown', 's', 'keydown', 's'], check=True) + release_keys = [modifier, 's'] if modifier_first else ['s', modifier] + subprocess.run(['xdotool', 'keyup', release_keys[0], 'keyup', release_keys[1]], check=True) + wait_for(lambda: count_records(log, '"message":"keyboard command received"', + '"command":"cycle-speech-mode"') == before_command + 1, + 'speech mode shortcut did not dispatch once') + time.sleep(0.05) + assert events.read_text().count(', s)') == before_s, 'speech mode shortcut leaked S' + + +def run_inside(directory): + root = Path(directory) + config = root / "settings.toml" + config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 1\n') + speech_modes_only = os.environ.get('CTHULHU_NATIVE_SPEECH_MODES_ONLY') == '1' + if speech_modes_only: + config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 1\n' + 'cthulhuModifierKeys = ["Insert", "KP_Insert", "Caps_Lock"]\n') + log = root / "cthulhu.log" + events = root / "keys.log" + binary = Path(os.environ.get('CTHULHU_BINARY', str(Path(__file__).resolve().parents[1] / "cthulhu"))) + processes = [] + try: + with events.open("w") as output: + probe = subprocess.Popen(['stdbuf', '-oL', 'xev', '-name', 'CthulhuInputProbe', '-event', 'keyboard'], stdout=output, stderr=subprocess.DEVNULL) + processes.append(probe) + def find_window(): + result = subprocess.run(['xdotool', 'search', '--name', '^CthulhuInputProbe$'], capture_output=True, text=True) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + window = wait_for(find_window, 'probe window missing') + subprocess.run(['xdotool', 'windowfocus', window], check=True) + nativeTest = os.environ.get('CTHULHU_XRECORD_TEST') + if nativeTest: + subprocess.run([nativeTest, '-test.run', '^TestNative(ModifierPressRelease|CommandGrabSuspensionAndRestoration|BrowseKeyTransitions|DispatchBeforeRelease|CustomBindingsUpdateAndRelease|TentacleBindingsUpdateAndRelease|ModifierConfigurationUpdate|LockStatesAndShiftCapsLock|XTermHandoffKeepsKeypadReleased|SuspendedQueuedKey|LearnModeCapturesKeysAndRestoresCommands|SecureInputDropsKeysAndRestoresGrabs)$', '-test.timeout=40s'], env=dict(os.environ, CTHULHU_NATIVE_X11='1', CTHULHU_NATIVE_BROWSER='1'), check=True, timeout=45, cwd=Path(__file__).resolve().parents[1] / 'internal/platform/xorg') + if nativeTest and os.environ.get('CTHULHU_NATIVE_LISTENER_ONLY') == '1': + print('PASS: native command bindings, press/release ordering, browse transitions, suspension, custom updates, and grab rollback') + return + app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(app) + def ready(): + if app.poll() is not None: + raise RuntimeError(log.read_text() if log.exists() else 'Cthulhu exited before logging') + return log.exists() and 'listening for focus events' in log.read_text() + wait_for(ready, 'Cthulhu startup timed out') + if speech_modes_only: + for layout in (1, 2): + for modifier in ('Insert', 'KP_Insert', 'Caps_Lock'): + check_speech_mode_shortcut(log, events, modifier) + before_s = events.read_text().count(', s)') + subprocess.run(['xdotool', 'key', 's'], check=True) + wait_for(lambda: events.read_text().count(', s)') == before_s + 2, + 'ordinary S stayed grabbed after speech mode shortcut') + before_save = count_records(log, '"message":"speech setting command received"', + '"command":"save-speech-settings"') + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'Control_L', + 'keydown', 'Shift_L', 'key', 's', 'keyup', 'Shift_L', + 'keyup', 'Control_L', 'keyup', 'Insert'], check=True) + wait_for(lambda: count_records(log, '"message":"speech setting command received"', + '"command":"save-speech-settings"') == before_save + 1, + 'speech mode shortcut broke speech settings save') + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) + assert app.wait(timeout=5) == 0, log.read_text() + print(f'PASS: layout {layout} speech-mode shortcut, three modifiers, repeat, release orders, ' + 'ordinary S, speech settings save, and quit') + if layout == 1: + config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 2\n' + 'cthulhuModifierKeys = ["Insert", "KP_Insert", "Caps_Lock"]\n') + log = root / 'speech-mode-laptop.log' + app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(log)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(app) + wait_for(ready, 'laptop startup timed out') + return + selection_probe = subprocess.Popen([ + sys.executable, + str(Path(__file__).with_name('gtk_selection.py')), + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(selection_probe) + def find_selection_window(): + result = subprocess.run( + ['xdotool', 'search', '--name', '^Cthulhu Selection Probe$'], + capture_output=True, + text=True, + ) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + selection_window = wait_for(find_selection_window, 'selection probe window missing') + subprocess.run(['xdotool', 'windowfocus', selection_window], check=True) + try: + wait_for( + lambda: any( + '"message":"queued accessible presentation"' in line + and '"type":"object:selection-changed"' in line + for line in log.read_text().splitlines() + ), + 'focused GTK selection was not presented', + ) + except RuntimeError: + print(log.read_text(), file=sys.stderr) + raise + assert selection_probe.wait(timeout=5) == 0, 'GTK selection probe failed' + print('PASS: focused GTK selection resolved to its selected child') + + xterm = subprocess.Popen( + [ + 'xterm', + '-T', + 'Cthulhu XTerm Handoff', + '-e', + sys.executable, + '-c', + ( + 'import pathlib, sys, tty\n' + 'tty.setcbreak(sys.stdin)\n' + "while sys.stdin.read(1) != 'q':\n" + ' pass\n' + f"pathlib.Path({str(root / 'xterm-key.log')!r}).write_text('q')\n" + ), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + processes.append(xterm) + def find_xterm(): + result = subprocess.run( + ['xdotool', 'search', '--onlyvisible', '--name', '^Cthulhu XTerm Handoff$'], + capture_output=True, + text=True, + ) + return result.stdout.strip().splitlines()[0] if result.stdout else None + xterm_window = wait_for(find_xterm, 'XTerm handoff window missing') + def focus_xterm(): + result = subprocess.run( + ['xdotool', 'windowfocus', '--sync', xterm_window], + capture_output=True, + text=True, + ) + return result.returncode == 0 + wait_for(focus_xterm, 'XTerm handoff window could not be focused') + set_active_window(int(xterm_window)) + wait_for(lambda: xterm.poll() is None, 'XTerm key-recording child exited before focus handoff') + wait_for( + lambda: 'suspended for XTerm focus handoff' in log.read_text(), + 'XTerm focus did not suspend Cthulhu', + ) + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) + xterm_key = root / 'xterm-key.log' + wait_for(lambda: xterm_key.exists(), 'Insert+Q did not reach the focused XTerm') + assert xterm_key.read_text() == 'q', 'XTerm received an unexpected key' + assert app.poll() is None, 'Cthulhu consumed Insert+Q while XTerm owned focus' + print('PASS: XTerm focus suspended Cthulhu and passed through Insert+Q') + + subprocess.run(['xdotool', 'windowfocus', '--sync', window], check=True) + set_active_window(int(window)) + before_unknown_q = events.read_text().count('keysym 0x71, q)') + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) + wait_for( + lambda: events.read_text().count('keysym 0x71, q)') == before_unknown_q + 2, + 'unknown active window restored Cthulhu command grabs', + ) + assert app.poll() is None, 'unknown active window stopped Cthulhu' + print('PASS: unknown active window remained suspended for Fenrir handoff') + + set_window_class( + int(window), + 'CthulhuInputProbe', + 'CthulhuInputProbe', + ) + subprocess.run(['xdotool', 'windowfocus', '--sync', window], check=True) + set_active_window(int(window)) + wait_for( + lambda: 'resumed after XTerm focus handoff' in log.read_text(), + 'leaving XTerm did not restore Cthulhu', + ) + assert app.poll() is None, 'Cthulhu exited while restoring command grabs' + subprocess.run(['xdotool', 'key', 'q', 'h', 'shift+h', 'b', 'x', 'l', 'Tab', 'Control_L', 'Shift_L'], check=True) + wait_for(lambda: 'keysym 0x71, q)' in events.read_text(), 'ordinary q was consumed') + assert app.poll() is None, 'ordinary typing quit Cthulhu' + before = events.read_text().count('keysym 0x71, q)') + assert before >= 2 and before % 2 == 0, ('ordinary q press/release imbalance', before) + + before_x = events.read_text().count('keysym 0x78, x)') + before_insert = events.read_text().count('keysym 0xff63, Insert)') + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'x', 'keyup', 'Insert', 'keyup', 'x'], check=True) + wait_for(lambda: events.read_text().count('keysym 0x78, x)') == before_x + 2, 'unbound Insert+x did not reach application') + wait_for(lambda: events.read_text().count('keysym 0xff63, Insert)') == before_insert + 2, 'unbound Insert press/release was not balanced') + assert app.poll() is None, 'unbound Insert chord stopped Cthulhu' + + before_return = events.read_text().count('keysym 0xff0d, Return)') + before_insert = events.read_text().count('keysym 0xff63, Insert)') + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'Return', 'keyup', 'Insert'], check=True) + wait_for(lambda: events.read_text().count('keysym 0xff0d, Return)') == before_return + 2, 'desktop Insert+Return did not reach application') + wait_for(lambda: events.read_text().count('keysym 0xff63, Insert)') == before_insert + 2, 'desktop Insert+Return was not balanced') + assert log.read_text().count('where am I command received') == 0, 'desktop Insert+Return incorrectly routed Where Am I' + + before_keypad_enter = events.read_text().count('keysym 0xff8d, KP_Enter)') + subprocess.run(['xdotool', 'key', 'KP_Enter'], check=True) + wait_for(lambda: log.read_text().count('where am I command received') == 1, 'desktop KP Enter did not route Where Am I') + time.sleep(0.05) + assert events.read_text().count('keysym 0xff8d, KP_Enter)') == before_keypad_enter, 'desktop Where Am I KP Enter leaked to application' + + subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) + subprocess.run(['xdotool', 'key', 'KP_Enter'], check=True) + wait_for(lambda: log.read_text().count('where am I command received') == 2, 'NumLock prevented desktop KP Enter Where Am I') + time.sleep(0.05) + assert events.read_text().count('keysym 0xff8d, KP_Enter)') == before_keypad_enter, 'NumLock desktop KP Enter leaked to application' + subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) + assert app.poll() is None, 'where am I command stopped Cthulhu' + + before_space = events.read_text().count('keysym 0x20, space)') + before_insert = events.read_text().count('keysym 0xff63, Insert)') + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'space', 'keydown', 'space', 'keyup', 'Insert', 'keyup', 'space'], check=True) + wait_for(lambda: log.read_text().count('preferences command received') == 1, 'preferences command was not routed exactly once') + time.sleep(0.05) + assert events.read_text().count('keysym 0x20, space)') == before_space, 'preferences Space leaked to application' + assert events.read_text().count('keysym 0xff63, Insert)') == before_insert, 'preferences Insert leaked to application' + assert app.poll() is None, 'preferences command stopped Cthulhu' + def preferences_window(): + result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name', '^Cthulhu Preferences$'], capture_output=True, text=True) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + preferences = wait_for(preferences_window, 'preferences window did not open') + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'space', 'keyup', 'Insert'], check=True) + wait_for(lambda: log.read_text().count('preferences command received') == 2, 'second preferences command missing') + assert preferences_window() == preferences, 'repeated command replaced the preferences window' + set_active_window(int(preferences)) + subprocess.run(['xdotool', 'windowfocus', preferences], check=True) + focus_log_offset = len(log.read_text()) + focus_before = log.read_text().count('"type":"object:state-changed:focused"') + def preferences_control(controlName, controlRole): + import pyatspi + def walk(node): + yield node + for child in node: + if child is not None: + yield from walk(child) + for application in pyatspi.Registry.getDesktop(0): + for window in application: + if window is None or window.name != 'Cthulhu Preferences': + continue + for node in walk(window): + if node.name == controlName and node.getRole() == controlRole: + return node + return None + import pyatspi + progressBackground = wait_for(lambda: preferences_control('Report background progress bars', pyatspi.ROLE_CHECK_BOX), 'preferences progress background option missing') + assert progressBackground.queryComponent().grabFocus(), 'cannot focus preferences progress background option' + wait_for(lambda: '"role":"check box"' in log.read_text()[focus_log_offset:], + 'preferences progress background focus event missing') + subprocess.run(['xdotool', 'key', '--delay', '100', *(["Tab"] * 12)], check=True) + try: + wait_for( + lambda: log.read_text().count('"type":"object:state-changed:focused"') >= focus_before + 8, + 'preferences Tab traversal stopped producing focus events', + ) + except RuntimeError: + focused = [ + line for line in log.read_text().splitlines() + if '"type":"object:state-changed:focused"' in line + ] + print("\n".join(focused[-16:]), file=sys.stderr) + raise + focus_log = log.read_text()[focus_log_offset:] + assert '"role":"combo box"' in focus_log, ( + 'preferences combo focused as an internal button', + focus_log, + ) + combo_snapshot_durations = [] + for line in focus_log.splitlines(): + if '"role":"combo box"' not in line or '"type":"object:state-changed:focused"' not in line: + continue + record = json.loads(line) + if 'snapshot_duration' in record: + combo_snapshot_durations.append(record['snapshot_duration']) + assert combo_snapshot_durations, 'preferences combo focus snapshots missing durations' + slowest_combo_snapshot = max(combo_snapshot_durations) + assert slowest_combo_snapshot < 100_000_000, ( + 'preferences combo focus snapshot was slow', + slowest_combo_snapshot, + ) + progress_snapshot_durations = [] + for line in focus_log.splitlines(): + if '"role":"check box"' not in line or '"type":"object:state-changed:focused"' not in line: + continue + record = json.loads(line) + if 'snapshot_duration' in record: + progress_snapshot_durations.append(record['snapshot_duration']) + assert progress_snapshot_durations, 'preferences progress checkbox focus snapshots missing durations' + slowest_progress_snapshot = max(progress_snapshot_durations) + assert slowest_progress_snapshot < 20_000_000, ( + 'preferences progress checkbox focus snapshot was slow', + slowest_progress_snapshot, + ) + # Start at the retained keyboard layout group and verify the + # current General page's actual keyboard path. + desktop = wait_for(lambda: preferences_control('Desktop', pyatspi.ROLE_RADIO_BUTTON), + 'preferences Desktop layout missing') + assert desktop.queryComponent().grabFocus(), 'cannot focus Desktop layout' + # GTK combo focus belongs to its internal button, which is not + # exposed in the combo's AT-SPI children. Check the reader's + # semantic focus events rather than the outer widget's state. + for controlName, expectedRole in ( + ('Time format', 'combo box'), + ('Date format', 'combo box'), + ('Progress bar output', 'combo box'), + ('Report background progress bars', 'check box'), + ): + tabOffset = len(log.read_text()) + subprocess.run(['xdotool', 'key', 'Tab'], check=True) + wait_for(lambda: any( + record.get('type') == 'object:state-changed:focused' and + record.get('role') == expectedRole + for record in (json.loads(line) for line in log.read_text()[tabOffset:].splitlines()) + ), 'Tab did not reach ' + controlName) + set_active_window(int(preferences)) + subprocess.run(['xdotool', 'windowfocus', preferences, 'key', 'Escape'], check=True) + wait_for(lambda: preferences_window() is None, 'Escape did not close preferences') + set_active_window(int(window)) + subprocess.run(['xdotool', 'windowfocus', window], check=True) + print('PASS: Insert+X and desktop Insert+Return passed through; KP Enter routed Where Am I with both NumLock states; repeated Insert+Space routed preferences once') + + object_commands = [ + ('Up', 'parent'), + ('Down', 'first-child'), + ('Left', 'previous-sibling'), + ('Right', 'next-sibling'), + ] + for arrow, direction in object_commands: + before_arrow = events.read_text().count(f', {arrow})') + before_insert = events.read_text().count('keysym 0xff63, Insert)') + before_command = count_records( + log, + '"message":"object review command received"', + f'"direction":"{direction}"', + ) + subprocess.run([ + 'xdotool', 'keydown', 'Insert', 'keydown', 'Control_L', + 'key', arrow, 'keyup', 'Control_L', 'keyup', 'Insert', + ], check=True) + wait_for( + lambda direction=direction, before_command=before_command: count_records( + log, + '"message":"object review command received"', + f'"direction":"{direction}"', + ) == before_command + 1, + f'Insert+Control+{arrow} did not route {direction}', + ) + time.sleep(0.05) + assert events.read_text().count(f', {arrow})') == before_arrow, f'{arrow} object navigation leaked to application' + assert events.read_text().count('keysym 0xff63, Insert)') == before_insert, f'Insert+Control+{arrow} modifier leaked to application' + + before_up = events.read_text().count(', Up)') + before_insert = events.read_text().count('keysym 0xff63, Insert)') + before_parent = count_records( + log, + '"message":"object review command received"', + '"direction":"parent"', + ) + subprocess.run([ + 'xdotool', 'keydown', 'Control_L', 'keydown', 'Insert', + 'key', 'Up', 'keyup', 'Insert', 'keyup', 'Control_L', + ], check=True) + wait_for( + lambda: count_records( + log, + '"message":"object review command received"', + '"direction":"parent"', + ) == before_parent + 1, + 'Control-first Insert+Control+Up did not route parent', + ) + time.sleep(0.05) + assert events.read_text().count(', Up)') == before_up, 'Control-first object navigation leaked Up to application' + assert events.read_text().count('keysym 0xff63, Insert)') == before_insert, 'Control-first object navigation leaked Insert to application' + assert app.poll() is None, 'object navigation command stopped Cthulhu' + print('PASS: Cthulhu+Control+Arrow consumed with balanced Insert-first and Control-first chords') + + for arrow, command in [('Left', 'select-previous-speech-setting'), + ('Right', 'select-next-speech-setting'), + ('Up', 'increase-speech-setting'), + ('Down', 'decrease-speech-setting')]: + before_arrow = events.read_text().count(f', {arrow})') + before_command = count_records(log, '"message":"speech setting command received"', + f'"command":"{command}"') + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', arrow, + 'keyup', 'Insert', 'keyup', arrow], check=True) + wait_for(lambda: count_records(log, '"message":"speech setting command received"', + f'"command":"{command}"') == before_command + 1, + 'speech shortcut did not dispatch: ' + command) + time.sleep(0.05) + assert events.read_text().count(f', {arrow})') == before_arrow, 'speech shortcut leaked arrow' + print('PASS: Cthulhu+Arrow speech commands consumed with modifier released first') + + check_speech_mode_shortcut(log, events, 'Insert') + print('PASS: Cthulhu+S consumed once with repeated keydown and both release orders') + + review_keys = [ + ('KP_Home', 'u', 'review-previous-line'), + ('KP_Up', 'i', 'review-current-line'), + ('KP_Page_Up', 'o', 'review-next-line'), + ('KP_Left', 'j', 'review-previous-word'), + ('KP_Begin', 'k', 'review-current-word'), + ('KP_Right', 'l', 'review-next-word'), + ('KP_End', 'm', 'review-previous-character'), + ('KP_Down', 'comma', 'review-current-character'), + ('KP_Page_Down', 'period', 'review-next-character'), + ] + + def flush_probe_events(): + before_f12 = events.read_text().count(', F12)') + subprocess.run(['xdotool', 'key', 'F12'], check=True) + wait_for(lambda: events.read_text().count(', F12)') >= before_f12 + 2, + 'ordinary key did not reach probe after review') + + for desktop_key, _, command in review_keys: + before_key = events.read_text().count(', ' + desktop_key + ')') + before_command = count_records(log, '"message":"flat review command received"', + '"command":"' + command + '"') + subprocess.run(['xdotool', 'key', desktop_key], check=True) + wait_for(lambda: count_records(log, '"message":"flat review command received"', + '"command":"' + command + '"') == before_command + 1, + 'desktop review command missing: ' + command) + flush_probe_events() + assert events.read_text().count(', ' + desktop_key + ')') == before_key, \ + 'desktop review key leaked: ' + desktop_key + for desktop_key, command in [('KP_Home', 'review-window-start'), ('KP_Page_Up', 'review-window-end')]: + before_key = events.read_text().count(', ' + desktop_key + ')') + for modifier_first in (False, True): + before_command = count_records(log, '"message":"flat review command received"', + '"command":"' + command + '"') + releases = ['Insert', desktop_key] if modifier_first else [desktop_key, 'Insert'] + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', desktop_key, + 'keyup', releases[0], 'keyup', releases[1]], check=True) + wait_for(lambda: count_records(log, '"message":"flat review command received"', + '"command":"' + command + '"') == before_command + 1, + 'desktop window review command missing: ' + command) + flush_probe_events() + assert events.read_text().count(', ' + desktop_key + ')') == before_key, \ + 'desktop window review key leaked: ' + desktop_key + before_review = count_records(log, '"message":"flat review command received"') + subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) + before_numeric = events.read_text().count(', KP_7)') + numeric_offset = len(events.read_text()) + press_physical_key(0xff95) # physical keypad 7/Home, with NumLock on + try: + wait_for(lambda: events.read_text().count(', KP_7)') == before_numeric + 2, + 'NumLock numeric keypad was consumed by review') + except RuntimeError: + print(events.read_text()[numeric_offset:], file=sys.stderr) + print('review records before/after:', before_review, + count_records(log, '"message":"flat review command received"'), file=sys.stderr) + raise + assert count_records(log, '"message":"flat review command received"') == before_review + subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) + print('PASS: all nine desktop flat-review keys plus window start/end consumed; NumLock numeric typing preserved') + + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'space', 'keyup', 'Insert'], check=True) + preferences = wait_for(preferences_window, 'preferences did not reopen') + set_active_window(int(preferences)) + subprocess.run(['xdotool', 'windowfocus', preferences], check=True) + + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q'], check=True) + time.sleep(0.1) + assert app.poll() is None, 'quit occurred before modifier release' + subprocess.run(['xdotool', 'keyup', 'Insert'], check=True) + assert app.wait(timeout=10) == 0, log.read_text() + wait_for(lambda: preferences_window() is None, 'quit left preferences running') + assert 'quit command received' in log.read_text(), log.read_text() + time.sleep(0.1) + assert events.read_text().count('keysym 0x71, q)') == before, 'command q leaked to application' + subprocess.run(['xdotool', 'windowfocus', window], check=True) + subprocess.run(['xdotool', 'key', 'q', 'Insert'], check=True) + wait_for(lambda: events.read_text().count('keysym 0x71, q)') == before + 2, 'q grab remained after quit') + assert 'keysym 0xff63, Insert)' in events.read_text(), 'Insert grab remained after quit' + print('PASS: ordinary typing; Insert+Q consumed; release-safe quit; grabs removed') + for modifier, lock, modifierFirst in [('Insert','Num_Lock',True), ('Insert','Caps_Lock',False), ('KP_Insert',None,True)]: + if lock: + subprocess.run(['xdotool','key',lock],check=True) + app = subprocess.Popen([str(binary),'--config',str(config),'--debug-file',str(root / 'next.log')],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + processes.append(app) + nextLog = root / 'next.log' + wait_for(lambda: nextLog.exists() and 'listening for focus events' in nextLog.read_text(), 'restart did not become ready') + # Give the startup loop a chance to enter native event dispatch. + time.sleep(0.05) + beforeQ = events.read_text().count('keycode 24 ') + subprocess.run(['xdotool','keydown',modifier,'keydown','q'],check=True) + if modifierFirst: + subprocess.run(['xdotool','keyup',modifier,'keyup','q'],check=True) + else: + subprocess.run(['xdotool','keyup','q','keyup',modifier],check=True) + assert app.wait(timeout=5) == 0, nextLog.read_text() + time.sleep(0.05) + assert events.read_text().count('keycode 24 ') == beforeQ, ('q leaked with lock keys or alternate release order', modifier, lock, modifierFirst, events.read_text()[-1800:]) + if lock: + subprocess.run(['xdotool','key',lock],check=True) + nextLog.unlink() + print('PASS: NumLock, CapsLock, keypad Insert, alternate release order, repeated startup/shutdown') + + config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 2\n') + laptop_log = root / 'laptop.log' + app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(laptop_log)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(app) + wait_for(lambda: laptop_log.exists() and 'listening for focus events' in laptop_log.read_text(), + 'laptop layout did not start') + check_speech_mode_shortcut(laptop_log, events, 'Insert') + for _, laptop_key, command in review_keys: + before_key = events.read_text().count(', ' + laptop_key + ')') + before_insert = events.read_text().count(', Insert)') + for modifier_first in (False, True): + expected_command = command + if modifier_first: + expected_command = { + 'review-current-word': 'review-spell-word', + 'review-current-character': 'review-phonetic-character', + }.get(command, command) + before_command = count_records(laptop_log, '"message":"flat review command received"', + '"command":"' + expected_command + '"') + releases = ['Insert', laptop_key] if modifier_first else [laptop_key, 'Insert'] + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', laptop_key, + 'keyup', releases[0], 'keyup', releases[1]], check=True) + wait_for(lambda: count_records(laptop_log, '"message":"flat review command received"', + '"command":"' + expected_command + '"') == before_command + 1, + 'laptop review command missing: ' + expected_command) + flush_probe_events() + assert events.read_text().count(', ' + laptop_key + ')') == before_key, \ + 'laptop review key leaked: ' + laptop_key + assert events.read_text().count(', Insert)') == before_insert, 'laptop Insert leaked' + for laptop_key, command in [('u', 'review-window-start'), ('o', 'review-window-end')]: + for modifier_first in (False, True): + before_key = events.read_text().count(', ' + laptop_key + ')') + before_insert = events.read_text().count(', Insert)') + before_command = count_records(laptop_log, '"message":"flat review command received"', + '"command":"' + command + '"') + subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'Control_L', + 'keydown', laptop_key], check=True) + wait_for(lambda: count_records(laptop_log, '"message":"flat review command received"', + '"command":"' + command + '"') == before_command + 1, + 'laptop window boundary command did not dispatch on press: ' + command) + releases = ['Insert', 'Control_L', laptop_key] if modifier_first else [laptop_key, 'Control_L', 'Insert'] + subprocess.run(['xdotool'] + [part for key in releases for part in ('keyup', key)], check=True) + flush_probe_events() + assert events.read_text().count(', ' + laptop_key + ')') == before_key, 'laptop boundary key leaked' + assert events.read_text().count(', Insert)') == before_insert, 'laptop boundary Insert leaked' + before_keypad = events.read_text().count(', KP_Up)') + subprocess.run(['xdotool', 'key', 'KP_Up'], check=True) + wait_for(lambda: events.read_text().count(', KP_Up)') == before_keypad + 2, + 'laptop layout grabbed desktop review keys') + subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) + assert app.wait(timeout=5) == 0, laptop_log.read_text() + print('PASS: all nine laptop flat-review keys plus window start/end; both release orders; desktop keypad pass-through; shutdown') + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def main(): + if len(sys.argv) == 3 and sys.argv[1] == '--inside': + run_inside(sys.argv[2]) + return + with tempfile.TemporaryDirectory(prefix='cthulhu-x11-') as directory: + readFd, writeFd = os.pipe() + server = subprocess.Popen(['Xvfb', '-displayfd', str(writeFd), '-screen', '0', '800x600x24', '-nolisten', 'tcp'], pass_fds=(writeFd,), stderr=subprocess.DEVNULL) + os.close(writeFd) + try: + with os.fdopen(readFd) as reader: + display = reader.readline().strip() + if not display: + raise RuntimeError('Xvfb failed to start') + environment = dict(os.environ, DISPLAY=':' + display) + environment.pop('DBUS_SESSION_BUS_ADDRESS', None) + environment.pop('AT_SPI_BUS_ADDRESS', None) + environment.pop('WAYLAND_DISPLAY', None) + environment['XDG_SESSION_TYPE'] = 'x11' + subprocess.run(['dbus-run-session', '--', sys.executable, str(Path(__file__).resolve()), '--inside', directory], env=environment, check=True, timeout=60) + finally: + server.terminate() + server.wait(timeout=3) + + +if __name__ == '__main__': + main() diff --git a/tests/ocr_capture_fixture.py b/tests/ocr_capture_fixture.py new file mode 100644 index 0000000..6917b6f --- /dev/null +++ b/tests/ocr_capture_fixture.py @@ -0,0 +1,135 @@ +"""Generic painted X11/clipboard sources for an isolated Xvfb session.""" +import json +from pathlib import Path +import sys + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk +from Xlib import X, Xatom, display + + +def main(): + Gtk.init([]) + connection = display.Display() + root = connection.screen().root + activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW") + clipboardAtom = connection.intern_atom("CLIPBOARD") + pngAtom = connection.intern_atom("image/png") + pngPath = Path(__file__).resolve().parents[1] / "internal/ocr/testdata/painted-text.png" + pendingCapture = None + + def make_window(parent, x, y, width, height, color): + window = parent.create_window(x, y, width, height, 0, + connection.screen().root_depth, + X.InputOutput, X.CopyFromParent, + background_pixel=color) + window.set_wm_name("OCR fixture") + window.set_wm_class("example", "example") + window.map() + return window + + window = make_window(root, 40, 30, 300, 200, 0x336699) + child = make_window(window, 10, 15, 80, 50, 0xABCDEF) + other = make_window(root, 350, 30, 100, 100, 0x992244) + windows = dict(window=window, child=child, other=other) + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + painted = GdkPixbuf.Pixbuf.new_from_file(str(pngPath)) + clipboard.set_image(painted) + paintedWindow = make_window(root, 40, 270, 640, 220, 0xFFFFFF) + windows["painted"] = paintedWindow + # The private fixture has an Xvfb 24-bit TrueColor root with 32-bit little + # endian pixels. Paint the checked-in image without adding another library. + data = painted.get_pixels() + stride = painted.get_rowstride() + nativeData = bytearray(640 * 220 * 4) + for y in range(220): + for x in range(640): + source = y * stride + x * 3 + destination = (y * 640 + x) * 4 + nativeData[destination:destination + 3] = data[source:source + 3][::-1] + gc = paintedWindow.create_gc() + for y in range(0, 220, 32): + height = min(32, 220 - y) + paintedWindow.put_image(gc, 0, y, 640, height, X.ZPixmap, 24, 0, + bytes(nativeData[y * 640 * 4:(y + height) * 640 * 4])) + gc.free() + + def activate(target): + root.change_property(activeAtom, Xatom.WINDOW, 32, [target.id]) + target.set_input_focus(X.RevertToParent, X.CurrentTime) + + activate(window) + connection.sync() + print(json.dumps({key: value.id for key, value in windows.items()}), flush=True) + loop = GLib.MainLoop() + + def command_ready(_source, condition): + nonlocal pendingCapture + if condition & GLib.IO_HUP: + loop.quit() + return False + line = sys.stdin.readline() + if not line: + loop.quit() + return False + command = json.loads(line) + target = windows.get(command.get("window", "window"), window) + action = command["action"] + if action == "activate": + activate(target) + elif action == "move": + target.configure(x=command["x"], y=command["y"]) + elif action == "resize": + target.configure(width=command["width"], height=command["height"]) + elif action == "hide": + target.unmap() + elif action == "destroy": + target.destroy() + elif action == "overlay": + windows["overlay"] = make_window(root, 40, 30, 30, 30, 0xAA1100) + elif action == "clipboard-text": + clipboard.set_text("Generic clipboard text", -1) + elif action == "clipboard-image": + clipboard.set_image(painted) + elif action == "clipboard-block": + other.set_selection_owner(clipboardAtom, X.CurrentTime) + elif action == "clipboard-release": + if pendingCapture is None: + raise RuntimeError("no pending image transfer") + from Xlib.protocol import event + request = pendingCapture + request.requestor.change_property(request.property, pngAtom, 8, pngPath.read_bytes()) + request.requestor.send_event(event.SelectionNotify( + time=request.time, requestor=request.requestor.id, + selection=request.selection, target=request.target, + property=request.property)) + pendingCapture = None + elif action != "state": + raise ValueError("unknown fixture action") + connection.sync() + focus = connection.get_input_focus().focus + children = root.query_tree().children + print(json.dumps({"focus": focus.id, "windows": len(children)}), flush=True) + return True + + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command_ready) + + def x_ready(_source, _condition): + nonlocal pendingCapture + while connection.pending_events(): + request = connection.next_event() + if request.type == X.SelectionRequest and request.target == pngAtom: + pendingCapture = request + print(json.dumps({"capture-request": 1}), flush=True) + return True + + GLib.io_add_watch(connection.fileno(), GLib.IO_IN, x_ready) + loop.run() + connection.close() + + +if __name__ == "__main__": + main() diff --git a/tests/ocr_click_fixture.py b/tests/ocr_click_fixture.py new file mode 100644 index 0000000..6aa4bb9 --- /dev/null +++ b/tests/ocr_click_fixture.py @@ -0,0 +1,34 @@ +"""Private X11 window that reports physical OCR click events.""" +import json +import sys + +from Xlib import X, Xatom, display + + +connection = display.Display() +screen = connection.screen() +root = screen.root +window = root.create_window(20, 20, 240, 140, 0, screen.root_depth, + X.InputOutput, X.CopyFromParent, + event_mask=X.ButtonPressMask | X.ButtonReleaseMask) +window.map() +root.change_property(connection.intern_atom("_NET_ACTIVE_WINDOW"), Xatom.WINDOW, 32, [window.id]) +window.set_input_focus(X.RevertToParent, X.CurrentTime) +connection.sync() +initial_pointer = root.query_pointer() +print(json.dumps({"window": window.id, "width": screen.width_in_pixels, + "height": screen.height_in_pixels, + "pointer": [initial_pointer.root_x, initial_pointer.root_y]}), flush=True) +for line in sys.stdin: + if line.strip() != "events": + break + connection.sync() + events = [] + while connection.pending_events(): + event = connection.next_event() + if event.type in (X.ButtonPress, X.ButtonRelease): + events.append({"pressed": event.type == X.ButtonPress, + "button": event.detail, "x": event.root_x, "y": event.root_y}) + pointer = root.query_pointer() + print(json.dumps({"events": events, "pointer": [pointer.root_x, pointer.root_y]}), flush=True) +connection.close() diff --git a/tests/ocr_geometry_fixture.py b/tests/ocr_geometry_fixture.py new file mode 100644 index 0000000..534754b --- /dev/null +++ b/tests/ocr_geometry_fixture.py @@ -0,0 +1,89 @@ +"""GTK provider geometry paired with independently measured X11 rectangles.""" +import json +import os +import sys +import gi + +gi.require_version('Gtk', '3.0') +gi.require_version('GdkX11', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gdk, GdkX11, GLib, Gtk +from Xlib import X, Xatom, display + + +def main(): + window = Gtk.Window(title='OCR geometry fixture') + window.set_decorated(False) + window.set_default_size(300, 200) + window.move(40, 30) + fixed = Gtk.Fixed() + window.add(fixed) + painted = Gtk.DrawingArea() + painted.set_size_request(80, 50) + painted.set_can_focus(True) + painted.get_accessible().set_name('Painted target') + + def draw(_widget, context): + context.set_source_rgb(0.2, 0.4, 0.6) + context.paint() + return False + + painted.connect('draw', draw) + fixed.put(painted, 10, 15) + password = Gtk.Entry() + password.set_visibility(False) + password.get_accessible().set_name('Protected target') + fixed.put(password, 10, 90) + window.show_all() + painted.grab_focus() + Gdk.Display.get_default().sync() + connection = display.Display() + root = connection.screen().root + activeAtom = connection.intern_atom('_NET_ACTIVE_WINDOW') + clientId = GdkX11.X11Window.get_xid(window.get_window()) + client = connection.create_resource_object('window', clientId) + root.change_property(activeAtom, Xatom.WINDOW, 32, [clientId]) + client.change_property(connection.intern_atom('_NET_WM_PID'), Xatom.CARDINAL, 32, [os.getpid()]) + client.set_input_focus(X.RevertToParent, X.CurrentTime) + connection.sync() + + def rectangle(widget): + native = connection.create_resource_object('window', GdkX11.X11Window.get_xid(widget.get_window())) + origin = root.translate_coords(native, 0, 0) + geometry = native.get_geometry() + return [origin.x, origin.y, origin.x + geometry.width, origin.y + geometry.height] + + def state(): + connection.sync() + return dict(window=clientId, target=rectangle(painted), focus=connection.get_input_focus().focus.id) + + GLib.idle_add(lambda: (print(json.dumps(state()), flush=True), False)[1]) + + def command_ready(_source, condition): + if condition & GLib.IO_HUP: + Gtk.main_quit() + return False + line = sys.stdin.readline() + if not line: + Gtk.main_quit() + return False + command = json.loads(line) + action = command['action'] + if action == 'hide': + painted.hide() + elif action == 'password': + password.grab_focus() + elif action == 'move': + window.move(100, 60) + elif action != 'state': + raise ValueError('unknown fixture action') + GLib.idle_add(lambda: (print(json.dumps(state()), flush=True), False)[1]) + return True + + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command_ready) + Gtk.main() + connection.close() + + +if __name__ == '__main__': + main() diff --git a/tests/ocr_review_fixture.py b/tests/ocr_review_fixture.py new file mode 100644 index 0000000..dcd4419 --- /dev/null +++ b/tests/ocr_review_fixture.py @@ -0,0 +1,101 @@ +"""Private Xorg key sink: focus, delivered keys and conflicting passive grabs.""" +import json +import os +import select +import sys +from Xlib import X, Xatom, XK, display +from Xlib.ext import xtest + + +def main(): + connection = display.Display() + root = connection.screen().root + activeAtom = connection.intern_atom('_NET_ACTIVE_WINDOW') + previousActive = root.get_full_property(activeAtom, X.AnyPropertyType) + pidAtom = connection.intern_atom('_NET_WM_PID') + keys = [] + + def make_window(x): + window = root.create_window(x, 30, 300, 200, 0, + connection.screen().root_depth, + X.InputOutput, X.CopyFromParent, + background_pixel=0xFFFFFF, + event_mask=X.KeyPressMask | X.KeyReleaseMask) + window.set_wm_name('OCR review fixture') + window.set_wm_class('example', 'example') + window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()]) + window.map() + return window + + window = make_window(40) + other = make_window(380) + competitor = display.Display() + competitorRoot = competitor.screen().root + conflictCode = competitor.keysym_to_keycode(XK.string_to_keysym('c')) + + def activate(target): + root.change_property(activeAtom, Xatom.WINDOW, 32, [target.id]) + target.set_input_focus(X.RevertToParent, X.CurrentTime) + + def drain(): + connection.sync() + while connection.pending_events(): + event = connection.next_event() + if event.type in (X.KeyPress, X.KeyRelease): + keys.append(dict(pressed=event.type == X.KeyPress, + symbol=connection.keycode_to_keysym(event.detail, 0), + modifiers=event.state, window=event.window.id)) + + activate(window) + connection.sync() + print(json.dumps(dict(window=window.id, other=other.id)), flush=True) + try: + while True: + drain() + readable, _, _ = select.select([sys.stdin, connection.fileno()], [], []) + if sys.stdin not in readable: + continue + line = sys.stdin.readline() + if not line: + break + command = json.loads(line) + action = command['action'] + if action == 'activate': + activate(other if command.get('other') else window) + elif action == 'key': + keyCode = connection.keysym_to_keycode(XK.string_to_keysym(command['name'])) + if not keyCode: + raise ValueError('Unknown fixture key') + xtest.fake_input(connection, X.KeyPress, keyCode) + xtest.fake_input(connection, X.KeyRelease, keyCode) + elif action == 'class': + window.set_wm_class(command['name'], command['name']) + elif action == 'unknown': + root.change_property(activeAtom, Xatom.WINDOW, 32, [X.NONE]) + elif action == 'conflict': + competitorRoot.grab_key(conflictCode, X.ControlMask, False, + X.GrabModeAsync, X.GrabModeAsync) + competitor.sync() + elif action == 'unconflict': + competitorRoot.ungrab_key(conflictCode, X.ControlMask) + competitor.sync() + elif action not in ('state', 'clear'): + raise ValueError('unknown fixture action') + drain() + if action == 'clear': + keys.clear() + print(json.dumps(dict(focus=connection.get_input_focus().focus.id, + keys=keys)), flush=True) + finally: + if previousActive is None: + root.delete_property(activeAtom) + else: + root.change_property(activeAtom, previousActive.property_type, + previousActive.format, previousActive.value) + connection.sync() + competitor.close() + connection.close() + + +if __name__ == '__main__': + main() diff --git a/tests/ocr_session_fixture.py b/tests/ocr_session_fixture.py new file mode 100644 index 0000000..e1813cf --- /dev/null +++ b/tests/ocr_session_fixture.py @@ -0,0 +1,125 @@ +"""Painted OCR text with accessible focus targets on a private Xorg display.""" +import json +import os +from pathlib import Path +import sys +import cairo + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkX11", "3.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gdk, GdkPixbuf, GdkX11, GLib, Gtk +from Xlib import X, Xatom, display + + +def main() -> None: + window = Gtk.Window(title="OCR session fixture") + window.set_decorated(False) + window.move(20, 20) + fixed = Gtk.Fixed() + window.add(fixed) + painted = Gtk.DrawingArea() + painted.set_size_request(640, 220) + painted.set_can_focus(True) + painted.get_accessible().set_name("Painted OCR target") + imagePath = Path(__file__).resolve().parents[1] / "internal/ocr/testdata/painted-text.png" + pixels = GdkPixbuf.Pixbuf.new_from_file(str(imagePath)) + + def draw(_widget: Gtk.Widget, context: object) -> bool: + Gdk.cairo_set_source_pixbuf(context, pixels, 0, 0) + context.paint() + return False + + painted.connect("draw", draw) + fixed.put(painted, 0, 0) + password = Gtk.Entry() + password.set_visibility(False) + password.get_accessible().set_name("Protected OCR focus") + fixed.put(password, 0, 230) + other = Gtk.Button(label="Other OCR focus") + fixed.put(other, 280, 230) + window.show_all() + painted.grab_focus() + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_image(pixels) + connection = display.Display() + root = connection.screen().root + clientId = GdkX11.X11Window.get_xid(window.get_window()) + client = connection.create_resource_object("window", clientId) + root.change_property(connection.intern_atom("_NET_ACTIVE_WINDOW"), Xatom.WINDOW, 32, [clientId]) + client.change_property(connection.intern_atom("_NET_WM_PID"), Xatom.CARDINAL, 32, [os.getpid()]) + client.set_input_focus(X.RevertToParent, X.CurrentTime) + connection.sync() + activeClient = client + rawClient = None + + def show_inaccessible_window() -> None: + nonlocal activeClient, rawClient + # A raw X11 client deliberately has no AT-SPI object or focused widget. + surface = cairo.ImageSurface.create_from_png(str(imagePath)) + width, height = surface.get_width(), surface.get_height() + rawClient = root.create_window(20, 20, width, height, 0, + connection.screen().root_depth, + X.InputOutput, X.CopyFromParent, + background_pixel=0xFFFFFF) + rawClient.set_wm_name("Inaccessible OCR fixture") + rawClient.set_wm_class("example", "example") + rawClient.change_property(connection.intern_atom("_NET_WM_PID"), + Xatom.CARDINAL, 32, [os.getpid()]) + rawClient.map() + rawClient.configure(stack_mode=X.Above) + graphics = rawClient.create_gc() + data = bytes(surface.get_data()) + stride = surface.get_stride() + for row in range(0, height, 16): + rows = min(16, height - row) + rawClient.put_image(graphics, 0, row, width, rows, X.ZPixmap, + connection.screen().root_depth, 0, + data[row * stride:(row + rows) * stride]) + graphics.free() + root.change_property(connection.intern_atom("_NET_ACTIVE_WINDOW"), + Xatom.WINDOW, 32, [rawClient.id]) + rawClient.set_input_focus(X.RevertToParent, X.CurrentTime) + activeClient = rawClient + + def state() -> bool: + connection.sync() + print(json.dumps({"window": activeClient.id, "focus": connection.get_input_focus().focus.id}), flush=True) + return False + + def command_ready(_source: object, condition: int) -> bool: + if condition & GLib.IO_HUP: + Gtk.main_quit() + return False + line = sys.stdin.readline() + if not line: + Gtk.main_quit() + return False + action = json.loads(line)["action"] + if action == "painted": + painted.grab_focus() + elif action == "password": + password.grab_focus() + elif action == "other": + other.grab_focus() + elif action == "clipboard-text": + clipboard.set_text("Generic replacement text", -1) + elif action == "inaccessible": + show_inaccessible_window() + elif action != "state": + raise ValueError("Unknown fixture action") + GLib.idle_add(state) + return True + + GLib.idle_add(state) + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command_ready) + Gtk.main() + if rawClient is not None: + rawClient.destroy() + connection.close() + + +if __name__ == "__main__": + main() diff --git a/tests/run-delegated-reader.sh b/tests/run-delegated-reader.sh new file mode 100644 index 0000000..3f6b8c6 --- /dev/null +++ b/tests/run-delegated-reader.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Explicit local development launcher; requires systemd 254+ and cgroup v2. +set -euo pipefail +if (( $# == 0 )); then + echo 'Usage: bash tests/run-delegated-reader.sh /absolute/path/to/cthulhu [reader options]' >&2 + exit 2 +fi +if [[ $1 != /* || ! -x $1 ]]; then + echo 'The reader executable must be an absolute executable path.' >&2 + exit 2 +fi +# Preserve desktop/backend locations without importing the whole environment +# into the persistent user manager. These values apply only to this service. +environment=() +for name in DISPLAY XAUTHORITY DBUS_SESSION_BUS_ADDRESS AT_SPI_BUS_ADDRESS \ + XDG_RUNTIME_DIR XDG_SESSION_TYPE XDG_SESSION_DESKTOP XDG_DATA_HOME \ + XDG_CONFIG_HOME XDG_CACHE_HOME LANG LANGUAGE LC_ALL \ + SPEECHD_ADDRESS SPEECHD_SOCKET; do + if [[ -v $name ]]; then + environment+=("--setenv=$name=${!name}") + fi +done +exec systemd-run --user --wait --pipe --collect --same-dir \ + --property='Delegate=cpu memory pids' --property=DelegateSubgroup=reader \ + --property=KillMode=control-group --property=TimeoutStopSec=10s \ + "${environment[@]}" -- "$@" diff --git a/tests/steam_big_picture_log.py b/tests/steam_big_picture_log.py new file mode 100644 index 0000000..2453f86 --- /dev/null +++ b/tests/steam_big_picture_log.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Check a fresh, physical arrows-first Steam Big Picture run of Cthulhu.""" + +import argparse +import json +from pathlib import Path + + +def check_log(path: Path) -> None: + records = [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + starts = [index for index, record in enumerate(records) + if record.get("message") == "starting Cthulhu"] + if not starts: + raise ValueError("no Cthulhu startup in log") + records = records[starts[-1]:] + + steam_pid = None + steam_document = False + first_link = None + first_caret = None + tab_focus = False + home = power = confirm = cancel = False + links_before_arrow = set() + for record in records: + message = record.get("message") + if message == "X11 foreground ownership changed": + pid = record.get("pid") or 0 + if first_link is not None and pid and pid != steam_pid and not cancel: + raise ValueError("foreground left Steam before the dialog check finished") + if first_link is None and pid: + steam_pid = pid + continue + if message == "suspended for XTerm focus handoff" and first_link is not None and not cancel: + raise ValueError("focus switched away from Steam before the dialog check finished") + if (message == "delivering accessible presentation" and + record.get("role") == "document web" and + record.get("utterance", "").startswith("Steam Big Picture Mode")): + steam_document = True + if message == "document caret navigation completed" and record.get("moved") is True: + if first_link is not None and first_caret is None: + first_caret = record.get("time") + continue + if message != "delivering accessible presentation" or record.get("cause") != "object:state-changed:focused": + continue + role = record.get("role") + utterance = record.get("utterance", "") + if role == "link" and utterance and steam_document: + if first_link is None: + first_link = record.get("time") + links_before_arrow.add(utterance) + elif first_caret is None: + links_before_arrow.add(utterance) + else: + tab_focus = True + if first_caret is None: + continue + if role == "menu item" and utterance == "Home, menu item": + home = True + elif role == "menu item" and utterance == "Power, menu item": + power = home + elif role == "button" and utterance == "Confirm, button": + confirm = power + elif role == "button" and utterance == "Cancel, button": + cancel = confirm + if cancel: + break + + if not steam_document or first_link is None: + raise ValueError("Steam never exposed its document and an initial focused link") + if len(links_before_arrow) != 1: + raise ValueError("focus moved between links before the first browse arrow; test arrows first") + if first_caret is None: + raise ValueError("no successful browse arrow after Steam became ready") + if not tab_focus: + raise ValueError("no focused link after the first arrow and Tab") + if not home or not power: + raise ValueError("Escape did not expose Home and Power") + if not confirm or not cancel: + raise ValueError("Power did not expose both Confirm and Cancel buttons") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("log", type=Path, help="fresh Cthulhu debug log") + arguments = parser.parse_args() + try: + check_log(arguments.log) + except (OSError, ValueError, json.JSONDecodeError) as error: + parser.exit(1, f"Steam Big Picture check failed: {error}\n") + print("Steam Big Picture arrows-first focus, menu, and dialog check passed") + + +if __name__ == "__main__": + main() diff --git a/tests/text-preferences.html b/tests/text-preferences.html new file mode 100644 index 0000000..90a58b0 --- /dev/null +++ b/tests/text-preferences.html @@ -0,0 +1,9 @@ + + + +Cthulhu text preferences check +

Text preferences check

+

SQL should be pronounced sequel. Braille should still show SQL.

+

Plain text, bold text, then plain text again.

+

Plain text, underlined text, then plain text again.

+ diff --git a/tests/vte_terminal.py b/tests/vte_terminal.py new file mode 100644 index 0000000..fb5489d --- /dev/null +++ b/tests/vte_terminal.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""VTE text fixture, including Unicode and trailing empty terminal rows.""" +import gi +import os + +gi.require_version("Gtk", "3.0") +gi.require_version("Vte", "2.91") +from gi.repository import Gtk, Vte + +window = Gtk.Window(title="Cthulhu Tree Probe") +window.connect("destroy", Gtk.main_quit) +terminal = Vte.Terminal() +window.add(terminal) +mode = os.environ.get("CTHULHU_VTE_MODE", "") +if mode == "large": + window.set_default_size(1400, 1000) +else: + window.set_default_size(500, 300) +window.show_all() +if mode == "large": + terminal.feed("\r\n".join(f"Row {row:02d} " + "x" * 140 for row in range(50)).encode()) +elif mode == "prompt": + terminal.feed("Previous output\r\nCafé 猫> ".encode()) +else: + terminal.feed("Café 猫\r\n\r\nSecond line\r\nThird line".encode()) +Gtk.main() diff --git a/tests/window_title_fixture.py b/tests/window_title_fixture.py new file mode 100644 index 0000000..d60a4a3 --- /dev/null +++ b/tests/window_title_fixture.py @@ -0,0 +1,69 @@ +"""X11 title fixtures for an isolated Xvfb session; no accessible toolkit.""" +import json +import sys + +from Xlib import X, Xatom, display + + +def main(): + connection = display.Display() + root = connection.screen().root + utf8 = connection.intern_atom("UTF8_STRING") + nameAtom = connection.intern_atom("_NET_WM_NAME") + activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW") + + def make_window(parent, title, application="example"): + window = parent.create_window(0, 0, 250, 150, 0, + connection.screen().root_depth, + X.InputOutput, X.CopyFromParent) + if title: + window.set_wm_name(title) + window.change_property(nameAtom, utf8, 8, title.encode("utf-8")) + window.set_wm_class(application, application) + window.map() + return window + + normal = make_window(root, "Example title") + desktop = make_window(root, "Wine Desktop") + helper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe") + ime = make_window(desktop, "Default IME") + hidden = make_window(desktop, "Hidden game", "hidden.exe") + hidden.unmap() + game = make_window(desktop, "Wine game", "game.exe") + control = make_window(game, "") + second = make_window(desktop, "Wine dialog", "game.exe") + unrelated = make_window(root, "Unrelated title") + windows = dict(normal=normal, desktop=desktop, helper=helper, ime=ime, + hidden=hidden, game=game, control=control, second=second, + unrelated=unrelated) + root.change_property(activeAtom, Xatom.WINDOW, 32, [normal.id]) + normal.set_input_focus(X.RevertToParent, X.CurrentTime) + connection.sync() + print(json.dumps({key: window.id for key, window in windows.items()}), flush=True) + for line in sys.stdin: + command = json.loads(line) + window = windows[command["window"]] + action = command["action"] + if action == "activate": + root.change_property(activeAtom, Xatom.WINDOW, 32, [window.id]) + window.set_input_focus(X.RevertToParent, X.CurrentTime) + elif action == "focus": + window.set_input_focus(X.RevertToParent, X.CurrentTime) + elif action == "title": + window.change_property(nameAtom, utf8, 8, command["title"].encode("utf-8")) + elif action == "legacy": + window.delete_property(nameAtom) + window.set_wm_name(command["title"]) + elif action == "class": + window.set_wm_class(command["instance"], command["class"]) + elif action == "hide": + window.unmap() + elif action == "destroy": + window.destroy() + connection.sync() + print("ready", flush=True) + connection.close() + + +if __name__ == "__main__": + main() diff --git a/tests/wine_review_x11.py b/tests/wine_review_x11.py new file mode 100644 index 0000000..b3f8ad8 --- /dev/null +++ b/tests/wine_review_x11.py @@ -0,0 +1,153 @@ +"""Private-display physical-key driver for the Go Wine review acceptance test.""" +import ctypes +import json +import os +import re +from pathlib import Path +import subprocess +import sys +import tempfile +import time +sys.dont_write_bytecode = True + +from native_x11 import set_active_window, wait_for + +def chord(symbols): + x11 = ctypes.CDLL('libX11.so.6') + xtst = ctypes.CDLL('libXtst.so.6') + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + x11.XKeysymToKeycode.restype = ctypes.c_ubyte + x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + xtst.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong] + display = x11.XOpenDisplay(None) + assert display + codes = [x11.XKeysymToKeycode(display, value) for value in symbols] + assert all(codes) + try: + for code in codes: + assert xtst.XTestFakeKeyEvent(display, code, 1, 0) + for code in reversed(codes): + assert xtst.XTestFakeKeyEvent(display, code, 0, 0) + x11.XSync(display, 0) + finally: + x11.XCloseDisplay(display) + +def main(): + build = Path(os.environ['CTHULHU_WINE_REVIEW_BUILD']) + fixture = build / ('review-fixture32.exe' if os.environ.get('CTHULHU_WINE_REVIEW_32') == '1' else 'review-fixture64.exe') + processes = [] + window_manager = None + with tempfile.TemporaryDirectory(prefix='wine-review-keys-') as directory: + if os.environ.get('CTHULHU_WINE_REVIEW_WM') == 'i3': + config=Path(directory)/'i3.conf' + config.write_text('font pango:monospace 10\ndefault_border pixel 2\nfocus_follows_mouse no\n') + window_manager=subprocess.Popen(['i3','-a','-c',str(config)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + wait_for(lambda: '0x' in subprocess.run(['xprop','-root','_NET_SUPPORTING_WM_CHECK'],capture_output=True,text=True).stdout,'private i3 did not start',5) + env = dict(os.environ, WINEPREFIX=str(Path(directory)/'prefix'), WINEDEBUG='-all', WINEDLLOVERRIDES='mscoree,mshtml=', CTHULHU_WINE_ACCESS_MANAGED='1') + secondary_env, secondary_window = None, None + def configure(mode): + subprocess.run(['wine', str(fixture), '--configure', str(mode)],env=env,check=True,timeout=5,stdout=subprocess.DEVNULL) + def activate(): + found = subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True) + if not found.stdout.strip():return False + candidates = [int(value) for value in found.stdout.splitlines() if int(value) != secondary_window] + if not candidates:return False + window = candidates[0] + if window_manager is not None: + subprocess.run(['xdotool','windowactivate','--sync',str(window)],check=True) + subprocess.run(['xdotool','windowfocus',str(window)],check=True) + set_active_window(window) + return window + linux = None + def activate_linux(): + nonlocal linux + if linux is None: + source = ('import gi; gi.require_version("Gtk", "3.0"); ' + 'from gi.repository import Gtk, GLib; ' + 'window=Gtk.Window(title="Wine review Linux fixture"); ' + 'window.set_default_size(400,250); view=Gtk.TextView(); ' + 'view.get_buffer().set_text("Linux review ready\\nnext Linux"); ' + 'view.get_buffer().place_cursor(view.get_buffer().get_start_iter()); ' + 'window.add(view); window.show_all(); view.grab_focus(); Gtk.main()') + linux = subprocess.Popen([sys.executable, '-c', source],stdout=subprocess.DEVNULL) + processes.append(linux) + def focus(): + result = subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review Linux fixture$'],capture_output=True,text=True) + if not result.stdout.strip():return False + window = int(result.stdout.splitlines()[0]) + subprocess.run(['xdotool','windowfocus',str(window)],check=True) + set_active_window(window) + return True + wait_for(focus,'Linux fixture missing',5) + try: + subprocess.run(['wineboot','-u'],env=env,check=True,timeout=45,stdout=subprocess.DEVNULL) + processes.append(subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)) + processes.append(subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL)) + wait_for(activate,'native Wine window missing',10) + window = activate() + diagnostics = subprocess.run(['xprop', '-id', str(window), 'WM_CLASS', '_NET_WM_PID'],capture_output=True,text=True) + print(diagnostics.stdout,file=sys.stderr,flush=True) + match = re.search(r'_NET_WM_PID\(CARDINAL\) = (\d+)', diagnostics.stdout) + if match:print('Wine executable: '+os.readlink('/proc/'+match.group(1)+'/exe'),file=sys.stderr,flush=True) + configure(12) + print(json.dumps({'ready':True}),flush=True) + for line in sys.stdin: + command = json.loads(line) + action = command['action'] + if action == 'keys':chord(command['symbols']) + elif action == 'configure':configure(command['mode']);activate() + elif action == 'check': + subprocess.run(['wine',str(fixture),command.get('check','--check-strict')],env=env,check=True,timeout=5,stdout=subprocess.DEVNULL) + elif action == 'restart-helper': + processes[0].terminate();processes[0].wait(timeout=3) + processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL) + elif action == 'linux':activate_linux() + elif action == 'wine':activate() + elif action == 'second-prefix': + known = set(subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True).stdout.splitlines()) + secondary_env = dict(env, WINEPREFIX=str(Path(directory)/'second-prefix')) + subprocess.run(['wineboot','-u'],env=secondary_env,check=True,timeout=45,stdout=subprocess.DEVNULL) + processes.append(subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=secondary_env,stdout=subprocess.DEVNULL)) + processes.append(subprocess.Popen(['wine',str(fixture)],env=secondary_env,stdout=subprocess.DEVNULL)) + def focus_second(): + found = subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True) + fresh = [value for value in found.stdout.splitlines() if value not in known] + return int(fresh[0]) if fresh else None + secondary_window = wait_for(focus_second,'second-prefix window missing',10) + subprocess.run(['xdotool','windowfocus',str(secondary_window)],check=True) + set_active_window(secondary_window) + elif action == 'virtual-desktop': + # Configure the same default desktop used by winecfg. A + # helper on another named Windows desktop cannot inspect + # this desktop's foreground, even within the same prefix. + subprocess.run(['wine','reg','add',r'HKCU\Software\Wine\Explorer','/v','Desktop','/d','Default','/f'],env=env,check=True,stdout=subprocess.DEVNULL,timeout=5) + subprocess.run(['wine','reg','add',r'HKCU\Software\Wine\Explorer\Desktops','/v','Default','/d','900x700','/f'],env=env,check=True,stdout=subprocess.DEVNULL,timeout=5) + subprocess.run(['wineserver','-k'],env=env,check=True,timeout=5) + processes[0].wait(timeout=3);processes[1].wait(timeout=3) + processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL) + processes[1]=subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL) + window = wait_for(activate,'virtual-desktop app missing',10) + result = subprocess.run(['xdotool','search','--onlyvisible','--name','Wine Desktop'],capture_output=True,text=True,check=True) + assert result.stdout.strip(), 'Wine desktop container missing' + subprocess.run(['xdotool','windowfocus',str(window)],check=True) + set_active_window(int(result.stdout.splitlines()[0])) + diagnostics = subprocess.run(['xwininfo','-id',str(window),'-tree'],capture_output=True,text=True) + print(diagnostics.stdout,file=sys.stderr,flush=True) + elif action == 'quit':break + else:raise ValueError(action) + print(json.dumps({'ready':True}),flush=True) + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try:process.wait(timeout=3) + except subprocess.TimeoutExpired:process.kill();process.wait(timeout=3) + subprocess.run(['wineserver','-k'],env=env,timeout=5) + if secondary_env is not None:subprocess.run(['wineserver','-k'],env=secondary_env,timeout=5) + if window_manager is not None: + window_manager.terminate();window_manager.wait(timeout=3) + +if __name__ == '__main__':main() diff --git a/tests/x11_focus_child.py b/tests/x11_focus_child.py new file mode 100644 index 0000000..a59ce91 --- /dev/null +++ b/tests/x11_focus_child.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Move X input focus into a child of the current test client window.""" + +import ctypes +import sys + + +def main() -> None: + parent = int(sys.argv[1]) + x11 = ctypes.CDLL("libX11.so.6") + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XCreateSimpleWindow.argtypes = [ + ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, + ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, + ctypes.c_ulong, ctypes.c_ulong, + ] + x11.XCreateSimpleWindow.restype = ctypes.c_ulong + x11.XMapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + x11.XSetInputFocus.argtypes = [ + ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong, + ] + x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + + display = x11.XOpenDisplay(None) + if not display: + raise RuntimeError("X11 display unavailable") + try: + child = x11.XCreateSimpleWindow(display, parent, 0, 0, 1, 1, 0, 0, 0) + if not child: + raise RuntimeError("could not create child window") + x11.XMapWindow(display, child) + x11.XSetInputFocus(display, child, 2, 0) # RevertToParent, CurrentTime + x11.XSync(display, 0) + print(child, flush=True) + sys.stdin.readline() + x11.XSetInputFocus(display, parent, 2, 0) + x11.XSync(display, 0) + finally: + x11.XCloseDisplay(display) + + +if __name__ == "__main__": + main() diff --git a/tests/xorg_suspension_probe.c b/tests/xorg_suspension_probe.c new file mode 100644 index 0000000..5bf3e00 --- /dev/null +++ b/tests/xorg_suspension_probe.c @@ -0,0 +1,66 @@ +// Deterministic native queue boundary: a key was captured before suspension, +// but remains in Xlib's queue when the Go owner gives the keyboard back. +#include "commands_native.h" +#include +#include +#include +#include + +int main(int argc, char **argv) { + int error = 0, failed = 0; + CommandDevice *device = open_commands(1, 0, 0, &error); + if (!device) { fprintf(stderr, "open: %d\n", error); return 1; } + if (argc == 2 && strcmp(argv[1], "queued") == 0) { + if (XGrabKeyboard(device->display, device->root, False, + GrabModeAsync, GrabModeAsync, CurrentTime) != GrabSuccess) return 1; + device->active = 1; + XEvent event = {0}; + event.xkey.type = KeyPress; + event.xkey.display = device->display; + event.xkey.window = device->root; + event.xkey.keycode = XKeysymToKeycode(device->display, XK_q); + XPutBackEvent(device->display, &event); + release_command_keyboard(device); + set_command_keys(device, 0); + CommandKey key = {0}; + int result = next_command_key(device, &key); + if (result != 0 || device->active) { + fprintf(stderr, "suspended queued key: result=%d active=%d\n", result, device->active); + failed = 1; + } + } else if (argc == 2 && strcmp(argv[1], "passive") == 0) { + Display *sender = XOpenDisplay(NULL); + int code = XKeysymToKeycode(sender, XK_Insert); + XTestFakeKeyEvent(sender, code, True, CurrentTime); + XSync(sender, False); // The synchronous passive grab is now active. + set_command_keys(device, 0); + CommandKey key = {0}; + next_command_key(device, &key); + int status = XGrabKeyboard(sender, DefaultRootWindow(sender), False, + GrabModeAsync, GrabModeAsync, CurrentTime); + if (status != GrabSuccess) { + fprintf(stderr, "suspension left activated passive grab: %d\n", status); + failed = 1; + } + XUngrabKeyboard(sender, CurrentTime); + close_commands(device); + device = NULL; + XTestFakeKeyEvent(sender, code, False, CurrentTime); + XSync(sender, False); + XCloseDisplay(sender); + } else { + set_command_keys(device, 0); + CommandKey keys[2] = {{.pressed = 1}, {.pressed = 0}}; + keys[0].code = keys[1].code = XKeysymToKeycode(device->display, XK_q); + if (replay_command_keys(device, keys, 2) != 0) return 1; + CommandDevice *competitor = open_commands(1, 0, 0, &error); + if (!competitor) { + fprintf(stderr, "replay restored disabled passive grabs: %d\n", error); + failed = 1; + } else { + close_commands(competitor); + } + } + if (device) close_commands(device); + return failed; +}

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�g8e!ckxT6-MH62r2RV=0Farx<4!TPNE*=Gn||m_9Kzv(&IOvv#xIXSvRz z!KBZ0@!#ox1^-I^tzlTru$gHC(_QAr%nO)iG9~>_|F8c~_utEZFaACK|NKAbu1C-r z2cUIPpmQnvnfjSbSj||^v7KVu!LpYnhB1QClEIo`J;PdtuZ%w#nV9}CGX3ZLU;4Y? zcjeEjpKE_^_&Mj#^gpiuz5ajx_x|7CKmY$!{I2+Y@AsYGhW||efyUUT{h$4R*WYb_ z<^L%DS@~zhAJhM){}Y*#m>OB?SvXmEShATin2sFDM*UU$ zrTWX}r`^wKzoz{P{~Pibv_J6f-PX0al*O$Q?yrva&P8jHXr>}p1 z{oVS1{eL57b7pB)3Dzwv+gKEt<(V59TNo9YWSP2H`dOy1_Oh;H-o`xTfA9ag-_5_- zf3f{y{>|`v(VrE6CjagEtNB;&@4-L&{>=J2_iyX}rvJf=p^Wm33XI489s1Y$r|VDH zpWr{;|GNH7Wt_#hhiNNQA!8XM=={F(f6xAn`WN#LwD;-kztjJo{(kYh z@+0L}{4dwv-oLAVm;UDYBlySSugPD~9z1Ji3+DUG51HREy=IDF3}<9z;9xk*aDw3; z<6}l;Mmfe8|K9vN`TO{9=3mUebbssr7X2&ySLL6|zpH<*|GoPA;_vgnuKwEiXU(7O z|9Aag$+Vp5BJ*YD4NPm9J~Dn~Ok$2_29?FCT5ONY&oRrgNwev)>$87k{m9C}%*uR);TFT!|8K$fT7k~*DgIaV zkLM5npQ>MFzxMq){OiJ>(|?}+d-ZSe|M}p1Zcj6uVQ6D&VUlH4V7b0KpIODYRLGcWU9 zh6fB4|4aX?{a635%An5B$I!)~_+RzE%3r0w-M_nj`~D92z39(^KNf#2{;vPC@z43+ zr+=UPb>^4WZ>`_Pe+~bF)|YK(*v`<-*uuDsaW&%!ro&9nnO`w8voNt7U_QW{#FWTX z!%)uvI;(Hp{|)~^_fFXUxA?!{@6x{+e^UQU`ZM`Y_TTKkU;lmm2U^1d8lQad@7_O- z|J?sS{(t-b1>;-BKxQ9iduA8rM@;vbmNPA9Drc@_c4u{Ad%HhtNvL3wf}qh@5R4L|CIi<{cZm%@mCyt zMx*~f|9_tU-N7@6H)|D*qx|10>H{kQP1%wO@p z5B@&+%kz)pU+&+czdQbH{KNl8{LigFm;TKExAY(A{>S5tM;QZ|!kDfyUSNE~_>M7{ zDU7Lysg>y~(_f~yOfQ&bGtFVT#&nZOnpvDVl{t;MjJbk2iYc5)icyx)n!$nrbdJ^Y ze-Hna{;U31{V+tBWNwRB9k(cJCifh6~-HkvW!xUFBx7l%ww3t06Gf| zbkC;Te}(@={|f*0{pAj`|DXT;{09mb*?%hk7XF?4*Ws`I-z$G^{jvIE^ylO6AHP5Ue)oI*?=8Q(ez*Ki z`JMH9@$dP+SNvJ|=j7ire;5Co_s{2l!2fOkxBds6jcdbb%gDsU#59v>22(3jJJV#w zen!wK9H8?VL2Eid>$XGx1^)Z~_t)QPf2aKgwMi=e*8b)E$NUepmk@Lo=cNDBzrG&&z!)Nzyvzm?JUD7@O`08|7-uV{pb3B>EHQ( z3;r$q_u=2CfA0TX{x|$@0^hNk&ydTogJBPYE2AUh62@hW!c3w}eM}up%}iZPzD({+ z{~1}BLKy=YCo@cAkY|tupQj8uV@348$bZP*@jd_g{`LF=l?UtpZTQFWpY=cJP9V@- ei@c12j66(SOkB)d%rBW=F?+FivRr1q!VCZvjjaO! literal 0 HcmV?d00001 diff --git a/assets/sounds/default/togglebutton_checked.wav b/assets/sounds/default/togglebutton_checked.wav new file mode 100644 index 0000000000000000000000000000000000000000..47c60e22832e0f04241e691fdf2d1e9e2728d585 GIT binary patch literal 40364 zcmWIYbaPuWmw_SNG0ZhBw?sjJfq{XMfr+7EHvOpP- z*$eUu$R3b8K;j_tLE<1gLFz%~f$Rpk7o-+s6-YgZ28n^>Kz4!R3#1mLALK3=4Uz|` z1L**83#ZTj85sDXaRRa*qz~jykoh1zpzr~i0Wu5Z7LZ>+W`OJi z(IB%x`ao*=pm7Z{10)7B2SkI!U}k{A86*$#4~z!c0kR9E7GyR^4&)z@+d*!H*$vYR zQV-G#3PTVL;=}9!sR6M;dO&(X=7H=6g$pQ7K0?zu$b67~kY7LiXJ8OSaz989$WNf~ z1(^rJATvSk0{I(c4k+wE>Of|I+yJrzqzeGs z(g!jF=2gom=@CCU8 zWCn;2lLNT}q!wg8$c-RBfYgA(3M2+{7sy<5_xV!!#%z#s(8qaeK?yFuoF%m9TY$Xy`6fb@XGKyCo}8$^T5 zfY}eS4-}ptGeL0(k_YJlnFUe<@(U>3L3%*p2r9!sZUcogC@x`gAT~%IWCqAy5FcbN z$c>=z1E~YK6J`#`k0A3w;Q?ZU^nm0*?g5E`>;<_6BnJ`$xe25m#0QxL@)JlM%-HFoEl4 zP*{R&W?=xinu*~b*zG_5|NhU%z|QdL|Ihy*KmYyD$nfX?pZ_3xK&h3L;otv1|3Ur+ zna9ikl4oJ~@&E6CUIu1{umAu3XJY{Q{m=j3;86SZ|J#2i1}+9hh9Ccbfki+*`T3uN zfs5hq|1V&lfYfj^a58-P|NB2E_CTTc`~TPfTnr)%zy7}lhY85vfB*mc|Mx#90~f>Z z|DV79nQXJlYw`1k++ ze~>w#+yIIzP&{*hLlx8l`}zO>e{KdwhTs4H{byoe0>>l+12Y3CW;q#t{{Qlyn}MC- z>wiWDPKF=<|NZA@`1$|se;x)whL8Wh{O4uhVEFYPr1$@SRt8pvAK({r0E#h?Yq=QM7{2}g{-2qFmw|=h8#LTOIrJ|$E%AX< z-{1d&3@i+v|Ns8a#=yn^^3%Kj91Nli-~PY-&(6Th@aI1$kNp16!T?Iqzrb!`VgSW8 zD1H9^56Wquz$prpzCkg?!SDwhv!HYV3iS{FfBokJ=L`n0+t?Y{!MWt$e|82&hOhrw z8Tc8#{(t|Un*rpaU;n@U2f2xZ;r;(F|5+H=7(ik0;{UJzLJXjE1!@of{m%|gQ=pJ# zV*rH*GdQ<=`wxn_zyJUK2bBk$;CusOF@wVj6fWEhEDRt2{{`nfkRL#)m4ktW0hBBL z{^w_4XZQlnO{@$Y42;r|bCScA+0<@ay@fB)wO`-Yq0=l>7? z`58nR-u!>}Ux0y?;n#mI1}290|Ns6MVqgTPHc)N@g~*%#|Ne_H{Qv*yKMOcT@i6@R z|NcKa0}nK2LFogOpFueVD0hNd-=I9k$iU9<_dlrA2K6^Uc^_2jK}x58|3M``C@+B02PlPu($}~Dzy5PG zuz_vTvgcyGR|L`9a|3Ck;GO#dw|Ns9#Cpe@*r7I&iMS*H= zNY4HZ&e=@h^8Dw2J_b&P5C6aZ7Xp_*-~Y2SfXXjWjscZ`~;U_%nYECm6-t~3QBK3!TAzoKgext3?LKO7?>D- zf_?ZE9Pa=BfB4VNAk6Uf|C|3j3<3KNCzmtfy(1=|G)g_XW(V{{Qt*)P<(SRfXYo!DFR9#piuqrpAlTgfbs~a zjuT~IVtDueAGq9PWcc_0J2=Px{r~MhGXpCFD93@q_sf4q1|D#21LA%D&&t39F6BY) z`Trl3x<5lp>p$Q!mm6I2F)(m3{QD1zPZkD{3%`NOF*b&;|9^u^h+qF189*)s)tI0j zFvzc;{(t+=2QD|>{byz11J|{nQksQpwFxLcgK88| zO90e&2bEx;dWn?*5?7#dO$b~Ef_mwoT9bhR)Pmvx=L<;r0CEMW)eQ0_$o(Lng4!WJ zz@Y`oW1!Fj)jl9Uf$Aer`3x%OA+;wf!_WUe{&O;b;`=AKX69!2@&C(zRt8WkfLsSk z1)wq?RHK2y_22(5;57d8|3_#_0)@zj|Np^dCMZ3DN-$7t{sy;U_!<8E2c=COa4YA> z|3Ck^7(jNs`_IN829^V*dQh1SN~6r+67SD{76uk@EeT55pd1D&w?K6O2g9fT-~V%g zTSK56`Tsv30}sQ;{~!MgFz|s(0RaXkhR^@m892bTFeEjD`WD~*gHjQwrN+g;$^fbb zxWIKRD3^oM>Yx80|AXuW#U=|lXR|V}gG*9S{rLAksD%kCu^{aRP`wLkv4CnCP)ss0 zu!Gxyp!Uh1|Dd!6s>j&DEiaJRzyF|I_Y2%M1?BMn|3S9?`wxn7ZU#_l{q-MY2B@|K z#o4d_5K};HIuILF4}SX(F$LllPznacFvuQIi~HYyP@T^XZnge`=Gu?{|NRHGB|s*C z^l~zM|Nj{r1E4l7FIX)o=Y!f=pqA?I|DZMssD1*a3{Yziln%at%MnnG$i%<~_9ZA~ zgK{q@&4JpApcDqG4S)Ur_@9+QfZ_Z9um3@H%AfyV{(t|^%OJ||^Z(2L%nYE|0hL&w zQj8N^w}Da*DEEU*VPF8|Ur-$c3TIG0VFI_(e*A~T86&vt2IUS&h=OWaP#MU?@ZtZz z{{rClG$eih{{QzsD9?e$u>OHVjfLUoe^C7fD$zkTD5wPticwIB14`SVIu6u|2Gyvb z_B1HmL2{t{`u#sBFN4g3w39%2j~P6Y2MSkEDgd>IUV~FKD35~t!^^XARmIte^9Li>XCr# z1?4|bT@R``LE!~TOQ3RziQ(=4U;hOe*um`_P=5jC9kP+Wp~grL0e4cvkUg)yk50&y$IUP$W)l+XTx`vag-;{X4DV73GUGsCn0pcXZ# zJqv1|eEcuOz{LP6bwO=dP>TZ;Dxlg3RQG)S&&j~g@b~{maN7ao9#9B?Mq2;;2eo%W zJ&51`LApRaB~UF2YOQfIfNB|529Ovi-a)wvR06Xw@G^Y+|LH#$xD^cwRZu+)>Is1Q za3FVcGyDVhCPDrKr7BR(%FFQk|HuDK44|9^ss$mXEGSp;fLl1AUMR@-fBt{_&&$Ba z@D|)6;RS~-$ZdiQ{0y)EzyHq%9tZjSACwP3y@G%LK{=Tp+)e@Ia!`#98aoF0ACwY* zLi>84mKHk$$Rtp{0@ZAwk^$6G1hJVJK&@9$tBr?&i{aD%FW?XZnf25vbM$wP+#b3@GJ*`g5Sv4(eC_2bWx5!96EXs)6*z zK>c}8%>wF+fa(ZP`vMfAppq8S`T*4)p!fi_wLv}yrFBr<3Tlmk`r`}?pp?$S0BYBR zav&Fj6vN;D&%pf^P|X0c3)CJ5r4Uf)f$|6+12@C>{~!LdF$gh$QWGf5L8%gw!ayZI zC{=)Z<)D5vC`3RssMpEDz{l|Q|GWRZ;4uSGSpjPCfqL4Yx)Rhz2jwbID1dT1XwCp+ z5~#lgD#1V|fW`y<{D1c!G~)n@Z&1nx^(sN(#ReWL0Ht`4iJ;aJsAmLfXM%bppt|?t ze|82?%>MrmawVum4Dvsy_W%nUP-`0GVn{z66i3|NnzZVNlNk)Sr6` z?lXhNM?fPiAoDQ8{&z`+1&rGU~V zC>8ww4{8g5>POH>##?aP<1@Iu3(6s&cmlPS1Q|eK25LEiY8+7A32LQ)Xi#Ye694u8 z(|=BIDgxE>pcn#`hM*A$E^xmE)H(*GdeGR^%m4rXOE7@yQcy4J8+arHRMzr<%PLTM z0kvg7xd2onfNC>PJp?L!LGc1g|Df;$l@Fjc4KD)|11Q`h|G(hTEH-evo{K@8;nV+@ z|AiO?7~cK={vTArg7Wv*|3CkWFmN)w1(%wjxB{geP~RM6HYlZoTmq_ZKs2Q80`;>& zH5q6&jRiawCd9za@aF&f{{jq<*aG=bgn^ym`Tr09LFMkR|De`0sBQ(dfk5L_ptuLM zOF%gmR2wogfcyX|fj}i0BX~X#R0@E?3siH1YG6?OgWBsLb3nNo)Ytd|9*F?8gg|2y zpte6~Y>bTo(yj!J4S?JXs+U3SZct4KYSnY1nL)qS`MK43)F)E zrDjkL0gVxXQah*|0=4czAqbKKg&L^;3Yt*`mB^r20hKPGkOZ~gKs5ku?%3vw5z1_$W@wVpsJ8dQdWYIaad{^Ng8jSEWMpb=C~1~G8$ z0t!n|f8qasP#+%Do&b$y{RfX;g2pI7^#y2@AJn!6)e<26pmGB=QVE(30p(XvdkoT3 z|NkE}+6!vGfa*X{3J2)}ji7?kJSf$Gk%5 zYQch1A1Ez>`VXMo0g__?j~#&g1*(Za?JrQf85IAZ9y7>hkgcHF1ms#!TOX84K(!dC zy$-UOpMi}5G%o=f*95f!K{X<%H3ll#KsgQ6lLMt@&{zSe`~tP_L9PR}fIvA3RBnOt z0;v57>VbiJQXu`HaRg915j4sJYHfk~{UF~#+Ek!6HK-&9jetVhKx_>B4DbH`{tv1l zK;z3y44~c*Xf*uCe+~vv{szShX#N)z7N9=t*Z&d>oD9$Z|NRe2gP;+aPyc`X7Xz12 zppkD-j}$ zP&xk(Jmw9mp+KYFpdKrzqym+Wpk4&1w+HIsgL+q>G6s}$Ky5U9QZ^~XW=J*3qI3Qth}1C{ik7zXJAr7%!E0ZPB1@jTGzAjn6cRy1fl7Bt!f z>H&k=a-0nR!1ElS@oUfsILM!%xB#VAP^koJlYRpCEkU6n!0_k)$N!-G1{#e9)%?Hz z3xUUHL16=mXHZCk>Tu8qB`X7H<_^@R0F`kJ;PHDPv#^Nl+aI>g|BaH_(_0C>B6%GSCPRXw)Ax+XEW+1+{QNE(ZA=R1bsZfk5M+|Ng%N zj}?JZAV?o5?}A#Apq>Y)H4d6>0F{A);8_FEJP03nE(p>N2Bk?*+Zf~zP%G=}e^9Fx zRBo_>$GEFR(ppxJ{xO@SPjei4=Vt_`mL9PUq!=Ty;RQ`hM8pvD~Xs!#?)&{j*e}m_X zK&2vRWDt~(LHP-kK6x3w|9}4&Xr)J@;-gh2TZ)OO)#0FAqV>QK=9GN@Pk?Y}q!KX{ZJ z)T;!wMnNOUpw<`2U!Xn`s5}q_&o6<->mmMzgCKYg57b@&jXQwmu0W%npZw?r4pb?rM|3S3~s8j-#C7_xERGWc% zN}#$Gq!!Y{1LZ$Z?*}r<0~*%@rA3f=pcxI2Euda5D|nR@D9?k!=iC3U{{c>>hh1GVcxu>*1|q|GGAzy{7wpganiCx@hMP_6{^P(fo(pjJ4jb_10=pmsc{ z&IXN7fm%h7ngKL!4yw&TwJxZP1ceC5CQ#cOGzS3cSApgfKy7bO3mY`X2x@PFVi{D5 zgYO-C_R8`PEgAQG%5?~IfCRsW0?%#**ws!5eLKX|Dd`Z)Vc=sF+k()pl}4u zqk`grp8+(F18Uv9`wwY#fa;RB|3P^a)Sdygrg*?}*q~V+NcsQoKd77mm8_uIF_8a3 z`5e@1292(R#wTE92`EQGX3;^e1GV%(Ap=qk>hpnWLQt9jr9e<@f?9o`)D6nrpccjN z|DaR~$~&MIE5sk5xvkIOxj9gZ0*$AEN<2_Y95fFBY8ik14@$wH*>*_l6Vzt@_8-*R z0L2n0*MeF!pTP4*puQkz^aL~_%EERg2s(N`3KaqV*$@5f!fKS7BQ%#fV5~pJwZ@k090;( z>Qs;(P%93U3P56@ln<)oK&v=F?f{KsfNEJ#YY#Nz&B6d0#Rs)`L9q_12|%Mmpx6WD zDNrjEH0K5?bwE8a(ApSKJpTR<8gm5A6@YRBsPzx3r$DO=zW#sx|Nnn6hA;m=f@hpx z{Rg!SKZD0JL8F!I;JH4~2qS2O71R#`&Fg~t?4WSwV*riug4Pv)YzO5ZP+AAIzd-#N z(0D&c1*m@v>IZ^qkzfBA7{nMraRRC(zy9ZEfV7k(z^e&BV`HE;DyT&O$}NyN0#JB? zR*mp5@IiZDpt(ZOSS83EpghFH@Cn)m2k8dYPmuBL|Nli8{{IJ!!-z4kf_wd-F4It-PH_8Z;sWN}-^(7igXwRJwp_Dp1V?O2?ql8Bloz z>I?n_`xMl=0EIYc{2Js3P`wCp4X9KEg*GVtf#%pjpf#M%j9)L#XK>ce_j~NoKpq?)%Re;K8P@N8%#|G&KjfI2eZXs>i z@6Zu!P)`_?(m=Ce@1Zlvps|QQ|3P&zsNDte52)n{s{24W9yF>6YVm=54ayOqng`TU z0*!HjR;_^MTS2R`K%;YCp`&1+5)d?Z37PAB1zz0*YD0p?sX(I$pz$uy%o`}AA$?Ym zuecdNK%9u+}r=3 z!K446F$Yk&2%6mhwM{|(1NC@8ZEHv?6(Rx}@dTC9BGCDFQ2PwjlL6HepuQL=XM)s# z+7F=e0W^05TDt-2RlWWXDj`5&A^@%dL95TcLgz<7f*zHVetAtP>Tpu0<(k1FGU$Rp>11G8v~RUKzR<-V+Gj+ zsw+VyG-%x!sMYWRJjVlS4S{CNLG!~b4DbGfQW+b=yZ`V0gX+L9;81uEUQY*FR|u-f zK{5O1|MUN#x#fTVL1R~-x&ksg4w@nP@*kAeL2W!x`T*q!P@5Q3(}TuNKs^&sZ2%ep z1ck%b|DYZXXzc-LjtDdo&JLcx1eHXfwLhR0UZ7kFnn{28A2iMnQUh`|XygObD+Kl4 zK`9p0V+EB~p#BKRJWzQAT2};`i3hm?G&{fyUP%I4-2$4O1I^Ha;vW>kpt24$;s8p6 zpt1*)PC#X;1OrGlXdM!$yac5-K?YDC@ilnu9;odH%E6!&0-!Pu6nCKd2Q&)G!~hz1 z1dS_$);oZDqM(rvP_G=+ssNQepfUqAj{=%WWM_E$pOHZryfy}uT0kulP@4i2gP?jG zRJVirmY}u}D336J*C>7e5Aq$T90BD(Pzeg^g@Q)rKw}x8@gq==8C0T!MnOUK+YfNx z7Sy5!jqZVFJ3%c6PVlTFXpR`vvH+z~P-_=7^2i5X)dQM80JRa`f>&R%GQ9r}D&asa zCME_@NeIf-Z^7$2K?^7^rLn)qj1S{LHPqT z*8?i4K|OoWnjFvy#jpQCE$~<1QEyPn=V5sL|1Eg!<;(vdy`Xsy(A+Sn=LbrsAXmHw zuXq8q!+!mL_MeqOh~dlsr{Ho7RQ`ba?!w@)5m1bRT8yCb12pCUYKMZxCqV6OkgGv$ z5YRXoXjYINyygwmW(TDoP$~rV8$fH?K=lErt_QVxK;;-{#1GW&1+80xv_V0kEy3{l z|Cj%7{|kcGn}NnBm>KxMYg$2Z|K~p_#=if5^R;b_K;csQm~EFVOmBP)iN8juMp1Kx>mg>r6Spt7t$ePXfGN`tyG_22lo( zO`!fYXv76n_k-3tgIe4i3<3u(6x3P)wF^LY!%941@Y-CE zU0=bm3##)#Z4Xd?6*N)}8e0OTCD3f85IEEr8Dtqi=?7GkfXW&!2GC3xXjcMg%{2>y zD0rm_D6T+x45R`ymIKPuZ=mbsKy4mj2GA%PDChnG&n1G!i9u`PK&zoa@d_#rL9?Eq zwmhh13R<1V#{lYqKw1c(`BG552x=dK+G`;5K`9UtN}yN<^%Ouevmo7|_yU!1pf#qT z_V|ncJPe@r7^oHm&9H#hy1x4_$pBh)3u-fhdJ&+K9MsYWwN5|(2lcr?r5&g|1FcXH zV_;yo2Ocv7rGL<96a#}M1E_8S%>sdX`k=ZL)XD(GI;gb+axJKZ^X5OOL;$U31NDGk z|7T`+{U0>m&B*ZVKWLpXD3(CuasU2<#*g$Ee*f2E0F8h1GRQE1>OUq13Glj?H~*i4 zOQHAwAN~LGAGEhafZ-W<#Uv;-UIwo?1Fg_yV7UAL)&J}Nc^IDl2d%^c%`kE?JpKRj zKNAC}9}P-3Tnvf~pjlOB26+Zh41ngRK;r{{|DXNO%&_M_0|Us_ppxmze+GuV|9}5K z^dHpvXJUBu|Lgw;|JfN{|9=5qM-7T0(D)i?q#aZmT>sC%S@k3xfs&Xa)$>P6f^M|NRe| zWdyBKeET2NGX4Mm6?hKy^M45jP)Q4#3j>wU91MaCps^8daBm;9T3M6f$A5kBjzLg~ z2wIQK#30T9=|4aD53-Ag!Ik0n|8fQv1}_Fs3*p!Qhv1bTd<^ak@BUjcfLbHW3~&E~ zM%qCoIx{%5-~8ub5Mf|uFk}FgIG|ea>wjGaW`;n9Z~v_sKso8te_@6{|HT;~JxkC^ zTu^QU&3#ERFf#}-a58|}R-lsa{C`G14|9=Js z83tj7$qfJhPh;R>h+<%4FlG4r|K5Lbh7bQg{I6pWVJKn%^;nn~PX1?QSofcu;SqS3 z1`osS|KI=5{m;k%3R4A!pZ`M`gc!`hv;Ls+m4_jO;qQNG27ZQ`|DXSZ*7tAw&&*)> z|JQ%7|J)3UpdB3l*%@a32lehiW`J7uEDTl*fBrWxurW*m@9YG%%LEvL7(V`YVgR*V zI2hLc|MuVYzam4||2O}SGKezdGO#e{{{R1f|9@GAtqkA)&t;Hf&|vuXUx`74!GYo1 z|I`0}|KISRkzpRgkN;MT>;H=XEDUS^voi$$|NXy#fs-MX@!$VA1{Q|4|2zzh|Ct${{O4jQVPIxx1n+a= zX1MnM+y8n776vT_Muv^xJj%v!?mr`g*Z=STH5fP;H2y*fV_o|Mouv!~1^>40`{+{7?DM!Ep88*Z(*E|N9>X z>W}{W{eS0w4u%NE@BbhD|M$Na6rYU0|GP4>GuZuSV{l>k_G|3Ch>{^Mat|NryH|JfOC{C)qw* z!^pz0>hJge*Z*-c$S{5T|Ml;W|F(bl8GbXq{@=sE&tUNP!+$%bkN@5OfBkR#^X305 zoUi|zOaA)5m;25Cn7=FxjlWnJHhuc@f8GZ^hHrn~{O@DsV%YVUi6Qej6NCR#0R~$! zW`@P)EDSQ-Yz)#bSQ#$zFfeSD=3tod_3QttkIW3R9IOmdKYssz_KcUo?cdM;4uZe_ zSNvsT5Pr$UkjBKqkSx#3V8zJ5Q2&veAzzD`VYN0pL-YMV{}Z14{GTDn#nAHd%l}E| zq!~KDfBrv{iI3sUS4M^{|9=0UB__gf$n?wqr+lIe+YbEx@BWpY!Nru1q3Qas|7j=r z7)qT#{a-En|NrJatPJM7-~XpOvN2qH|NejOZB~X0EZ_ex;$dM3|M&Yp+tUyK*RZfK zINAUBZ~T^>p>hrrL#i+n!!I`mhWAHU7&34C`yUj}$?!|~@Bbxxxf!;K|NbB6z|1h` z9UsHLhhP4G6XRp}EB^k!!aHsT-|PSWJ2CPw7#OlKIR9g0u)fH~a8Tse{}Xaj3?*k6 z8Du|8FnsX%_urEB|Nn1${`}AT^yj~%$e;f!|9|^`i}~08VwV5^ZGLevZ2s}*|L(U8 z3>xg8|7)B4`Y+GS$8hTyKf{B+{0!>K5)4vrc^Ks0OEWx_<6-#E@c+N?eP)IX)^Go1 zq?s8W|7B;;dHeVO;y;`WSt7swM+yG^@A-w5;l}rO|78Uk8Jz$A{r~ti2ZIU^2g3&r z0fv=#gczbk3D8yjK`tScm21bV3U!n}1fB*fz&m_gr%q_r>$->4U z`;LR*OU((2+NQEaf}QMAAZ02pZQ;r;p1OEhMr%{4BTvg|A+B@{jbKv&hYW~tN(5P9{e}@ z&B##q=i~ogju-!(c)$HGVgB;J`#T52zVBcEuVr9i5Mz*GaQe&3pvx-GP|qaB5dDRP zVb`fZXIP&9H~z`X zko{YfL6!N>f4RR540ryCF|>1vFq~uHXK47r&fw4T@4p@Ega1*#pa1{Qz{}9b`scqI zcOA_{So_5Y2M? z|9OU2|Jxak{eScG%757(pZ{0?`~5!$RP!+KF*q@aF~t1iV-WbO%Am*a|Noai-~Vs> z{pr6tGatiCW_^Z&Kkxsi{(Agh`p@|Em8d{@?ib@c-mL3Je9mY#4g} z$}(`W-2C6ky6e9U>(&44?7|F|EZht+|IhwsW7_rqG3&wq{4B@+yE9(?zyJT~|DXOc zFueTZ#IWV>xBo90&4LamzklDQGh{#3Dm~^^FR0B>;Er*^D)T( zl4r>KW5AH{&w)YxuOoxbUt5M*|6~|C{yqEe`TyO2JBF|SCI4|T=>215P+|P~UyAYV z|C0<`|6gD__WwJ>{Qs{14*b8(#KB<7p3HEEWd_4xMkj_^hF%6?hBk()j1df-%pnX% znIagB7|R*D{t7cV{krl0!rzAf7Z_{*|NP(mf78F?|G5|r|F8Mi_J94~+5b}+r~mI} z%KKmazv%z!f3yB`G93E9?Y|3y#D8H1&HpF<_x+#q--^Nf|J(n*|JDC9GCckF^S{l% zBmbBFoBRJZR8+u&M~VqL@+-7Kab(@ z|7ZWh8BQ^#Gj|KA*jssFnf7#Jrrs4_}07%~0-Kbf(YVaLA-443}pG6*x6Fg*Hq`2ULksthw2 z%^8{gKVwMzGv&X;zd!#o{xdRe_`jDSlR=N+;XfUQxW9@F6aR1iKY?-i|9^~a3=5fE z8HJgjGZ-@DG0gryn_<=eE(ZJmtPGZn7yrLwkYy11E68B}m*@ZEe|P@f`*ZDo?%#d? znHW?4OZ{KUAoe$hF_)pA;TOZK{~G_N{nuc6@qYzNGQ$DJScY_lL;ue)Zv20O(T;(S zk&EHu|E&K{|K0t6@_*5PMuu1aH2>ZHum8`Eq51#0|4#pV7{317!f^e6$p5SVh5pa@ zugQ@6KaD~Df7t(H3_kz$nG6_G7*7A+{qNwvo&R6|>-qoj-@|`l|6&;Y{@-CZ_h0;9 z%Rm1=VgEb+J!E*x(D~nmLGu5-f3b`@4F4G3Gm8D6$l%0~#ISe-UHMzt;ag|F!-tVa)um&iI01@xSH&S^oF^b7IJ5 z;QjxVA@RTRe+I_L|GWObV)*n=m*M39>;L}$jro7&uRMb_y@c#eRFWDHj zoQq&M@ja8_A=4QK`)^+v#QxuBU=>$m{AL=%Aj!Yv|BM4K8Qy&9V?1DD$FS^LJ45-2 zV8(K%um6wA-2TtCCy?n9Pc4(5qZ-4T_m}^#y}gFvE6Z7i#XPGS*#5aQG(Fk<{{b^E zLz&(4|J83E{JTH<);}L%_W#W8`u|doCj7g1W8VKW;m-eqMdtjQz4!FLY2v>BKiab~ zn7m_Sod0kl!*a2k|F4R-|9|r~^S|hIA%>?6o{ZZJvKV*$HDWk=;m!X-kv;#n%9SuI zKcmW+`*}Y@n5Q^HEo&5m`d&ST!cV{d3y1_TF8%M$$iY(m{}@Zt|8u`M88Uv}{8#p_ z>i;`-amIBf_ZYS?KlyKZyobT?-#LalxR=KHr9+*mFD z>r0>dZ}!iUVd>lK{~>=D|7R4{V7MeG@xSoPo_{;O`!O67&|rN0$DCpP>#zSzcn_hm9Oak+@JjaSA3cE&xQHXo;QypQRSX3jf{dEH+Zarlc^Qnq+c1QG&tx!Q*vfF|e>~%tKX({Du|)r`U`qHO z@#W_K*}sh#47jH;>|@DiX!>Ks;P?L=L-g;147b1j{;&0G4udXJ8e;*+K88aq)(nO} zZ!(1ap1~l`{NewlKS%!`_{YUCgL4l9BV!lClOKuy53(HlcZgN||F_>v|9u&m|DR+1 z^S_wUp5Zt%-~VrncmHYr4re&aXw0~nt&>55@$LV57A1yU*1G??KkxmU|MSRyHC9iC z87v3>YcVP?oMJf3;P|VA!R|*IxE7OO`24?`p^@PeBP-+Yzb_b0{CUst@4pShzdwHe zxBkA&z{||W)WclGVD(3lA@Y|ZURNy z#;*c~t$!LATK>KL|M1VB|2zJ?W6=Khfx+=#D#PV}UJUF1Klp$4Z`=P*e;@xp#_;-o z8H2}v0S4RuB1|U#tr%4Pm;XKb--+qi|0isf|F*O2`nQpB;r|qdfd7B~Py4@~(TyR9 znT?^DY3_ejMlOb#e_0r2{c8Ph_OI`MC=&}qIm2WIvH$FhF$}g0+y5>4fAen-!$QV% zhObO3{y+U+!yxvbiBW?=nBmEPZU*lET#Wbr$uoNVPh+_HA2foz^S}20t^Z&AlV(W$ zr_V6)Un|39#zhPVnFIb`V|?`Q#(zErzyA;aU-|d--&2Ow|1(+Q7&b8L{%2r(^v{`L z&;RHDJOA%z`1p^9S?B*##>xK={?laW`nQ*1@;^m}i@03a{m<>8ve^M1uz_AQe{wQ z`T0MbIp@DE!^MAl|115!_W#8H$p2;xqKqdP1Q?qc(*7F!FaP`I-{*hN|F8Zd&#>_? zKZ71)FN4khYKEJC+!>-7*cmJt-~YG&|MLG;#-9Jzm}~xdq%eZX8*SR zuln=$fBCe|~|HNO`|J(lW{jbh2k>SMuECyBvV+J3F zxeTccy$tmXsSIZsv=}5AnHc&Q7yjSN@af-&|9AeGG3Nj0VG?7gW#D0mWsqQCW_1c*z*f0GiEu_TPcw4ucQF4+c(#2Mlli&tY(5FksMQa08!yaqj<@ zf1m$f`xnjd_g^~0!@u|bTmIYmfAxPshQ|Lv3@85SF+BRm1j_vkFaC!y%wl-LAjmL< zq5Z!f!^Hnz{xAQp{6B+1DTC-_F3yu>ZdjWAFcW46KY3 z7`Pbu7!UrR#}M=1fq{h~n(^y@UB-+5K_fa*|5d>|V3q#c{%`;9_g|HJpI1~UduhI#*M|9k%r{{QyB1A`7jI^+5OeV|hIza7Kn|L^}#W_bNyj^W&Y za|UOIdWH`SI{#}Krv01oAGAwn3Bx^xSN}a3bpOjT{Q9TI@cCcEf0O?~|8xJlGR*qF zlR=Uplfi=_n_(J*EQ3E|(SLKsL;o!qzW(3#fBOHs|1bQX`QMVk`+w#CL;o%QFZ?f??l( z4u(_zzy06Iu;u?ghLiuD8Q%WC!m#E)1EVYhE5kR2{{P(!eE-E6Uj0j9Fk`4>@L>G( z|H*%8hQI%07&iW2_uuJ%&i~KM{!eDmWng36@V|i}_g4`u|FXwf}t>v>8_acVc+_ubM&n|Gob#|8xHv{10JZXJBG{`#+!I=D*qhyZ^QR zmu0B`ug{qKe>OwYe`AJ)|3eub{h#&!>VL`qjsN8t4*U;d$Y99$fByfm|8xE?W?00~ z%W#_^mO+jo1H6N%h#`PMi{TA}Aj1p>Ylch)QwBx`DTes}*Zy}fZ2iBRQIH{v!Jpy9 ze>sK|443}%G0guz|Np)JlNllylo(1G1pgN??D}89Fzf%5|4aYn{9p0^^#34+LWVv6 z&w@_4`NzTVQ{~yl4%dp_T9>cf)xBpLPuxDUo$YhBBAJ4$V zaDhRM;TuE8|9uP_{#Sy>9rGBz{nue=W6)s;VA#g6{QrE0-T$pXb^iZ||JNDL{J;J` zx4egFUbfAjC@ z|IGi#{{LlAVTff=Wk~^S=N?|NpxGcmEgt4`+DzUyDJOF`Gf2aUa8?|9k$Q`2XSG5(Wc? zlm9CjHvX^u|A=Abe;$S_|1JMr{&(X)&;MS=&;J`3!v8P)|MZ^|18D8kF$U-VOBiPU z-@pKx1yN*BW~lhD$f*6Ffzg*?&Hu9u4*$jffBhHnzxiJ)7r+q8u$94yVG)De|5*$t|9dg4WYGM-km1q4Z~q+`9{%@Z_{gyNe=mbP zgVMhj|CjyK`ftK`=f60^N`|(7g$$1wiWs~YB^g-$+yB4xU;qD1#xwu>8O#`(|KDWT z{J(VGd%yV$l%Vf=Rf;@1%{XZ%@|HGJO;0!KKs9sL6Kqp|IGhZ|Fs#o81fl2 z8Q2+aF_`@S&5*}%_rCx`-G8nBo(!oBUJPLj5B{J2_XE`4`G1#T_kT@>m;cZHZ}|V? zfBpZ;|Aq{~3}^l)Fv|b8VGv|E^Zz1)E5n5Ush}B!e~FCK8Ppm581Dc7@$b!l*Z<)R zYyW@vFVDcp5X)G@V8-an;QZg|f8hUT|6CY)|8p@oFoZBfGpq*J*^&&Y|J4{m88R4L z80r|#{h!LP;Qwxhg#UpId<>KS-(*<+|JeUd21bSs1~tYL|MeNp|DXH+?*Hiw;tX5= z_cIjz+sLs0|7?Z;22F;A{{tA98GIPx7>pUj8CV%w7{31xVvu0i|G(q^0fzMdwhVF% z{r~D1R{XF2e~!WH|LXssl_x)l4|K~HjWJvix`M>7>YyZms=QAAougsvs@Zw(%LokCrLoTD= z{~(5>|11AnGUPEVXXs)uVwn5iiy`X&Wd?DERE8D?R)(PewhXfWfBg?&u=y{_aQwge z{|JT(hF|{|GBPsU`CrGN`Ja!0nPJ&~ZU#?=kN?XU?lY|U-_D@%|HZ$;|1>oENIyYK%u z25p8IhBN<#|M4=oGO#gPFtIX%b~4rcoAm$e{{#QGGA#cu!Eoij>;JF+Sr{}Kj2UGa z-2cz~pZ3@NzdC~#!%0SN#xwtz8D{*u^S_?akzo=;?SG4Z_x{ZT&8{%9F+5{X`hV$P z=l}Bm77W4+pa1{**YyAHza#%08A2J78K*JS{1;{T_BY}GVut1a?U;BN7XLr^-|zpb z|6EL;|IcLT{eSdt;QuxMOBuQu|1((sU&4^|&xk>d;oARxhRy$X{1;)^_urJ^7{iYL zn;E4To-xQVF#n(Y-<9F`eDkTfA#+lGc5h@$@uC2uYVW+@B7Ea(8yrGpvEZ8u<^e)!@_@y z7#{pz$pC7Vto>)ru=L-D|9csw86p@<7!3Zs_^K#&EknkJ|9XrI z{{Q>C;{TVwzyELjug{>ycdV|Lyqi&LG23&yd5g;J*}jNAZmRj^G-+hQav%rT^FeulOI& zu$tlb|J(mp{TKY-`u`up*8iT2fBtv;_hmTwKbs+d!Gz)UKTZa<|C|1IFo-d1X4u6L z!LXh|nBnpNvj1)W+y5{9KbPU*|15@U|G)p=2Rngeg7F54*ZY*&&{xyL4cu);s5_%{|p#*{7+%XVbEsC`@iXb z*Z<4^vl(m{^cZF`C^4upG%@V@|LK3%|AYVA{?{>ZGiWjNGpIA1Vwl2^%K+L(Yw~~V z|Ihz*816C5Vi0AhWtjc{|9|=a7ysA)d-DJ9e=UYLjF}9X4Au-T|1bPM$Z5YDK|aN*z8|4ska{r~!Z7DGKl7~}i@It;u23;*|I*!RDPVHJZhXjjkw zlK&ozAOBl2F8hDsAH)AEpjk)8;|%@`H~;(o^Zf7pKY}5E;WC5i|E~W$|6l%-VT@!* zVpL&d{eS(x)c?Bw{7iNXU5pG2o&Q4rd;b?^Si+FO!1Mpse}(^B|JyKzGh{QqW=Q*= zz+m)G{{Nl--v8?vCo`BbEcqYz|M0(f#sY>n47V7({%QR`@&C>LM~r!l8ULp+zyB2& z+ZZ4IQ)Gz#7xF)a!IOcH$&|s3;mH5Ozl;Cx{9o|@E#pjvjsFfaDE{O8FU&OkzZBzi z#-6{t3|0)2{z)-2Fdk$GWK8{k>0cAWg#UgFevC{1UH-4|@4&zP{{xBuE0{QtZDPh?hvkOkkMw_sRbi3@iUjFr_o}{soOJ zrT$N6Jo{gtDUxBu|HuCy{J->Hnn{4+9mA9VTmA|$1pGH(XkqeU2x3^s;PzLCq4B@g z|DTLT3@`u3GM4;%^k0~<@&7_5KZY0o%>RG-6aBA@fsbJ}<0^)w|F-}4_!1E5i?l8w|((2r`)eTk`)GlMTZS1_{PJf7uy08M^-oGG70m{ZEVW*}vNV z{~3M$pJCj@xc=WFh7Et^|LtaY_J1m)AXEE4P!6#D-^^^q@Q7h4!_7aF|Ihv}|Nj@0 z1!ye-W6{3}|0@`c|2<~B@c-1GHw?yqV*iISP5y7kyo+)E-wuYUe^>pR$mqbZjv%{2E*zwboo3)+`uwk+ z(c*vhzk>{g{}cYRF-`q<;(rTs_%QAGZ_l`gA@?8XL`K{HeN5W_*Zg1k zKkDx;hNp~749?86|FQjbU=aTMiQx)U*T01f^Zxt&Nn%>?e-nc$^V@$X7*ZHM{@u(F z^8fz-Y$j0#bH;i9@BfwlZ^dBu|2tFPKa+p0|Cj$>&gj6np242!;Q#EuL5za`Eg008 z8UJl%IP(AS-xmyf{=5F~XFB!Yj_DQy>;D=5Q~xdezmYM8L5FeP{||pI{lD~Y8^cMa z<^R9^GyA{bj|jtV#%PA^j2es~e|;JE{^$Ac%zXG?8)GWNj{ole_y0@yul=utDUQL4 zamoMNe@*|^GJImtVe(?)`aj{1^#51?0vPjH6aU|2IPiDkpLhS~GDtEuGp=B8`>)UN z7*r4aw`KVLUyy;7vGCuIzX$)D|4U>FVYX*TU^xA^>o=$^)x>y~dGo((f0zD?_$SD? zfhmc>nPJyI#eYlxPh@aqlxNz_kj4=CfBxUj{}ca;GCDHlFf=p!{d4@M@?Y=YEXI$F zFaO*AfBm=RZxmxClM|B<(~rM~e`f#v_*gPEQaELdzmgWyZ={U5dC-hPX=QVgD|5b)2hD<{xAN= z{jZa8<^Sajd`z4Eon#PX2>knu(Uf8D|0m45{?`Bd_5acb^YV`SHw{L ze=1`gv)uop|DXQ#{w`)RVOYUf#eD0pE5nR`=Kr2DurL<7VUCTSou?!c5#uzyA9&zlQ&hFo^wS zV|@DG`o93Wpg{q!_L+RR90-PlAEvza#?_^Y?$Z8SDSs{Rw4U{a1(K zE+fx>ssBxkOaHECT*x@#p9Ryge@FkjFlaGs`FD>YyUuZ`i`Ul9gQrrQ5pjLnSu|N1hPGi3Z-%c%SBcmI$3U;5Ya|02dO|Nk)5GL-+({y&-F>c3Lv|BNkv=Q6eZ zjsACz`RPADMl0s^e?KuSVwnH;7vqQjlNhoYjQ?NzbKqat-)e?PMh?a%#=^g{|4;n; z^Ut06C&RpdhnQ-AEoOYcsK5}*8o>DIUlqgrf4lzVFnj#1VTfhQ`{Tj%=)c^*SxkEW z@BNEn+WY6^{{V)4e_t_4GxjrVV!ZO#|6dt{;lCi}P)5K1j~HkD+5TUHiQ&IGvlAoZ zpXrQ${&xShXMOUAona@V?k_DSPlhjlCoygMAO80;v)Vrc25n~MKjzE_|1bH^!BqTr z(mxvpsej4Lg^X$celhI(&HnEkQ}+K2Ommt1e@il{G9>)tXK(o%_J1`4_pkj7dl{bn z&14E^xczrIbK$?K|GzU%|Kr2N%DDJH5A)f-LH`vQ5C4l|ZfD%~w}i3l&)I(mnC}1E z$8do0#xEnLEQSgHQdqbDt^1$B$ndwAp_XyapNWi9|L^$Q&fNVspW!LPjla=M4Geq! z-DAxETk?Mk)2@I2nRFRH{qbVF`_Jv)3g&fx1ONYEc=Nk~v56u4pDYv4|7HJtnbiLq zFmyA?|BYkHWzhfkfNA%i?EhOB75-H*KV|svcP)d$zZ?H%uw40fjd9}tC%-Eg@)*AS zzsX$nf6m`<#^B#YjFya{|4JB5|9}21%xw4P1_K*Y@n0jB9sdOxS{R=Gnf3q9|8&Mk zrZUFff8qZt{+<4B!1CyyCS$|j{9me!vH!i9?y(gA`}F_AzoNez7?T*K86p{+7&!l? zF}40vWBkwf@?R5k#Xot*u7CM|of$U$zrdi+?c z7_=D`|9k%L|9hA5^FLMQSxh1SCo_Hg8}@(Kf7kyq3`Gn-|L6RdXV~(eo57#al7W{& zg<<`_<^OFMQW-ZeJZCuZKjm)%!=-;!3{#j+{(s4k!EosBEr#R&QyH=t&ohYrXJx$o zH<;nve<_9>hSdMI|33U(^3VU@SEk7SX3RVP3;(~taQWW~#z@8-23ZEJ|EYiH{lENg z)BkKnrvIJ{od1vgjrm{o|KvXgX3hWhj87S6{C&Y_&#;fNh3PCq+P}O1R{X8_$H3h8 z|1i_rf1&@h|4;c}%vi$sm0|fm;eUF6oBr?o&&cG!G>PFMqa(w)e@=`A|FsxAnFaqJ zWN`i;^;e5w0%JZ?GNU+?@~>O}ME;aAoM+Zy(q>fo_w&!jzuy>sGHzk&VOaY=^smYP zpZ_;9TxQtLDEhyd;U~kgzfp|*3~&E)GVft1|J(lm$*+#THyL;S-_E$1;r?%_|91a> zF<3MIWN7~H`*-&L^nWERFBv8=pZ%Zn|29L@za@;5|7ZWpVKDoD{a*>w-9H-{JQ=M2 z_cIwX)cni)_vPQ;zYCbv7%~`Y{>T5B`M-rBk?}E87Q@$n(-`*tTfz8}(ei%~qxyfR z|D{Z4{_kUOWeoe*_5UD4?4O?hH~-H6W5g`S(9g7tN#-9vL)Cxd{~e4k{wx0b@PFT5 zK}H>>+YCJnhZs2jFf+COYxy^uY1wZr#%GMn|8ua~GRpjGV?6dp?SDUG(O)KprT;ko zJz_Y-Xvk2)`21hMUv0*>f0-Cl8O{FsGVlGz%b3Ny`A-U?&OgO}@yy>D-ZOAB3H?3) z&ysP*|E-L(nJ)g0WSsl&{J&b(!he~Jcm9j~na_~*|JQ$Wrbqvi82lMS|KDNw$MEdm z1g5DBIt)BaF@LuGtNwrE&mU%O2CM(e8MA)p{0m`p{ma38kU{wGXU4XFEB@E9lrs1- zDli5AzVnas-`77vOiLL){@cS;{pZ&I`wYtel31Gl?_hZI-}vv}e+L=-{&zFKWnA>H zj?wRr{NI<168|)qrZ7AGj%Tv{tMyNgm6PEgqXARvZ-xJ1|9}3S$y~(1^uLC&@z2Ts zoJ>9c_*g$Pa4~FWy#J^6Zx&fxDTV`# z(tjWQeeh78N=uQM;Pju=l)OoxAK3=FL6eFrl|jCS(^U*|6TR(>hHOXax8EE-(WiV zFXOKvW6FPaCPpS^#+3i_7?%Dy#31=Uk8v4u8$$-u&A+05XZ(}=@5pGyvf{rPL*&22 z-{1b9VtDiK74yo!{(rACwElU@G?V!y!$+oKhUvc<8SnkJXS~VC$SA^e?%%_|TmSC; z`}bcOW6Qs2M!$bS|9TmZF~&0TvfcRG`Ty(xre6=3)-oPrxX7Z*(EeA0@zd`E|7S3W z{a^n7=KqC%zx|76%=quZ3_6+p)L*~9?*DfE7iV0-Y{jU^G>ehp-+89?|2hndn123i z`p?Dm?5{kd7^B?3JOB6pul~L7zsKMEe_|Ow{BvNu$M~H=j9~_&@BeTA3mG{7KlpFU z5Xf-je@>i^IGvHZ)z$i>LWV#V}?Ve0??3`PGZGtU0+|JRh^&YyCoKt?ZyF2<0*%l_Lk zWc}a5;PF4`Z#lzzhP#ZeEL9Bs|3VoG{~9nUFz@(p&$R6Cxqm#2RSbMg+Zp!$+xm~; z|Fr*&3|$QR{~8!d|0*)7F(m%WWo-TX^uHrh=6^e;xr{}BGnwA~d-boA@%NuEf5RA# z{QJoe%KYWe0*1+dWB=$eUi_EB;K=CCP{Z(-iI1U>Dfs`B{~P{m{y)LM^S||f>;J$1 zxfyJkBL2Jlmty$vM}qMPW6*z2=8gY!|A{i*`lreGi?Q#26GI2Xg8y$AN*S;HzstzV zkj9Y5(DzT6!R$W=LkrWJe<6$&|0ey7WjOOMg29lX5P&80{^T3pU<#_$%<*)|HuE;|7`!iieVXJ z6Z5-&1`MTtYyRJ2c)^g%Ao73r-~PWJ|NH-c!FYf{lwrYt9fpU0xfmD!FK0+#{>Ko{ zq{tZd-}t{LL-_wU4EO%6|7ZF4$-koi!i+B&MHtTfpZ|B=|JZ-EO#c`T{WoNE`5X4% zhoS2KP6mC(DSw|b82n3QkYwOtie%_ytYnP;ca6c}--Um63?CT!7~Ggd8JPZ0_`mr7 zj(>_wt^YUut7R1Vr^eXA?E62PY1+T}e_a2~_N=W+rZSwFpqH&W63{-zc&B>{bga;%w+fPKI5`~ zG5@wORQ$Wmu#mx&VZ;AghQ)uy7`Og!Vm!fggyH9Zq5tRpy#H_a{~kjVBj>*t|JVMV z^e>+2`+qOSpA2??=KhQQZ}?A%o9FtPl-!Z7FS~zJ{?#!yF(3Lb#HjYyf#z zzr?7|vh;u0|2O{+{&rzJ#k7lIAD24_9yO<)FZ~lG#|Jv^bzpnoO%y58FfbHx5^uI>`7XM87|B@+~=^D#x z#&3TN{vZC^!w|!~nepBKsed(p=l#|Dzk}&6lOa>h|EYfy{!aY2k->uT4pY$o|NpiB z{r}s*V9q#^v75=1;n3e@|M&kn!1$PH1Jf#I_5U(|Z~oc)cgg=7%#93Lj12!S{ucbN z^52}%iFxJ!AOBAL-~Hz-BMVbG!$0On|C;_i{CDAx;Qw!oe2j6-GntbA{rrFCPt$)} zMt?>T#`1p=e-8g``R~Q3!g}xj$Nx9~KK}KJv4n94lP&A@|2zI!F>d>l#kiKS{hvGI zjDP$7EdQ7GkNuxC^KS+R#uW@F{!ISs_CJsD6th3`V}{0miGQ^i;u&n2e=x@Wv-o%5 zU;JNJmivqw7-EQ&>T>hW-xAw0CV**1FLl@JX-wXaO{{R2?BBra1QvVh)UHLozUovAj!$OAJ z%v1koG0bDQ_{W%0jlqrSHNp_M81Z|XnqfAW9bm>Zcx7}hc$ z`qT5bn$hQf0P{X(i~j}xH5ror2ry?b++=vkc=(^k|BC-U|B4u{{h!2O$$07Cmj88s zwf;@`Z_k*(pu?o~f9c=b4153WV7$hh^-r3y^WWybGnm*JCo!&NeD(LiU*-Sj|Gr|J z&T#7gB}T=+4h*;dANzNmaXRC#zaGqs{{;Q(WW4+*kBN)v_&-I)bcWbJ7EBfYD*qj3 zeEWCNe=eq*|8_FEGB*AzW&Hb(^Z%>=%l;}dLB#ZU1YSPy9RaZz02KhR=-O7?uCUF&zAx^Ix3#@IQAZpZ_6$Ss5(; zzxZ>9vE?tz|2IsM|4SJHnF{|HG5ls+@pm~(5rgmFr;I=UME@6H;rUm=wDjM+Ka2mT z|1DvRWcbGb8d<9QXUVYqUoqn{Mt+7?CO5`2|9>!MFkJXo!F2Io#eZhTiT~{X2QcOS z|H5FxwCK7Ix#-^U&tWGc=+ErhRF=kOz#*U zFev}e_;dJQ?7uGzYnc}R4`*!ux9tC221N$Yx~blOrVL*h9y6FQ`u?x~H|PJ%e^36` z|Bw3n?SJh5wTwZ`{r~6xzxg-zZw?bD(>kU|#%+IH{_6jiXZX(eifKNB@qcMX)xQV- zp8>TA7+IK}GITNM{NMJ=ilOdbHshoJ8~;WBy7G4-!wIH4EZ>+Ee((Du@>k$*6q_7# z@qbySjlZJ*J^nAjc>ZrGlO99D-}$UAzu){_@_*akLdH)lCI7O2SN%2oeVL(y>m<`^ zCMyP&pJD$cST{1=XI#rP|7Z5!PBy#0A6asl)_pf%D*3PUSB7!J-(^3yF>PXLW?aX{ z{Kw&M#y=T0b(R9QR}6E075vp;mSDQd;>XPV)AVl~)4e~7SuL4De?4WK%DnK;MCO%$ zK72mI_>QTXX(~s|Kba3#{;U73X0a8BV6gwO;J4eaj|_YG|1)p-eg3cX*ZMzOI36>y z{rSeY@yCz9raTG%js9pbvwvC5SjnyZ_b;Q*Kf#~O%-;Mqj1w5H{4x2o@Lvo6Nk%qi zR))i`rTjkCrobUtZ3z-}lZj$QfxFUB^8 z1PM>Za^*|^ul{3TSh&NSaq8ti|2L+}Ff57R&d9wz<-hK6iNCCtng18wRA#7|&B^dn z_W`4d=UxW>i!T2K@BCr(mo#By`eXNR!`Dp=339CrPI@+fu89l(Yk2U2ao2;z42jBN z484EJY)S;6pDrThQqZ(V;b z--`Wj{Qe~41F^0DE~p*)7xM2clf#3vOn?6tGtB$-fnoopPmE2UuP`_$?fkd$-<7{2 zPu>4N;;dj;$glQ)>3ggH@4wGw%$JW~@Kk;CSL4qDhQ`}POcUPi`?r$K_Fweh&x{3Z zbqqI^!u}rSEcyTZraI&8*OC8gB(?rKiq$fFXJBMlz;yfnfl}t6F24Uq8A|>eyh&kX_#(tKm3!&`uUwn|$+C(w zGIIR>|M$n;zuqsVGw%BGg(-vG;qM{tAAdqPt(ian3ua#X+3MemuZ#YE`?sIr9p@(o zO+nRv51GFIoA7=MlgPJphFIpgzn`#NW7xtyn{_Ymv%l+p_5S+(ftktouO!o9rYFCX z8D;)IW~*VI$7=9@<1hEWCEu%=p8d3Ej`+FvU&|lyzphMw{!25NGu8ZS`7iqS_TR=o zb&UW2q%*lO_5N++SpN3{>lUV_Us_D!Us?ZM`SbDLboNZfN!)G>vpMJd^Jf0?kLy<; zgWI<$3>E)0{wH!OG2G|UWv*p8%JBd9+P`PMdNBR{F`wl%(}TYp?E8L)vL-W5V@YDr zVp{Wi`+s=`Ck7jqY5%SMvoX&3lx6R*a3{L+iu{>e!Ww-fX%JT2mbkJDwufI&2zUBOD{y*){R;~^I z=d!3WzWg)ue=g(Ke`QR67^)ejFfC*I!_daD;J*_?A*1r26^vp3cK?6Mpvs`kbdSN3 z;o(33|A+qOFn;|1>fa`Y_kXPaZ(wfxx1A;C|I6QgjC_CYF+5-j{+Iq=ongn1UgpT3 zy$ln6ss1hfp~ay1lYzHpIiwlUTI=VQqGuLzn4X8gk($gqOx)&DOH?M&EW|FZvG z%!~eRW1RJu6_f-2ss7Dl&|$dEFp<&iZ~Y(kKNEi`F&O;KW%&C0>+e&48vn;LxvrZB7z~)NGOlLqXa4`kpZUtatp8sb0{>S2tN1_n z?=wa&#)(YPEFO%$OmhF%{uTNs_J8%CH;g5}=Yv+G{3~U7z--UBjdA7goqs(33Nvsr zCNsG)u3~6q(r28;GXK9imM-rlbE_nBM$x{=Mj*%D;mQnhf^;KK^?7Yx94e|5;4Sm<4~`_;=**@83Nv zsZ5!FdziodZT#oJbm#w%-&RZ)8Seg9W9R(;{nz6E7k_gzB(v8uX8$SrZ}98K-*cR- zOb$$68O(lt|IN&{j)9Xgjg|Y?jQ{W1&i<2Tn#D5f$71FKEEoQ-XYl_&>6`oiGpw8p z#%y{21%LkgQ^F$1u#VN4QSXQKFDE9wUu`UmOy7RkGl;Xk|6Rnkfid>m0%k|1+`pV` zynpX~KE|lZbe8c5XVc$X?>+uJ{dItmjo*pI@4Nm#gCE@gR`W)&+WlI~^zs}3-)%g$ zOc8%wSl0gN`P;*n_qYA;HOBp)^cjD0-T%LjQI&!5M=Zk|{y4^D=7$XFU#>AU@}Fh- z#~{LT;nlvs{^IBVJ{I`P@a?$y-}>8Pe|#m~8J+)WG3dSF{k>UF@6T*!qyO7qU;UT2 zzv163wLSkslJXc6?{zS3opFSrM=<$culQkx7b_B&gnk+PUlR7_U+_bICiV;J4D0-y z{+^W;Vw`hclVz4>0Aq#Tl7ENJ9{FebY}>yhZ2bS#oZHpM=|y|IdFFFv(x5{kKte!*F*#GGB&PngP<{G;+rNG$-W}qMO;?2&=BEE;FC)>|jXt+QK4nK8^9_9p--lGA4{>fBrKWeO~zQpiJYRIK6y^UWqhDlZVItYlX$IRMEhfX;!T(vd^!y2z+4b+I#s|iSzb7%A=GFXrm2cBO z(GNO|8qXj7d;jX!-*k~Y>%Wu#Br`phzR%>uzUKdCS!RY6>P^gj zU%oNzyK&(E`VXc5cZx0ge^ur9-|&AY|Ajny!8Gfy1oO*pd;fb}jQThA+p7P1%DWk= ze{cT3=t<&#Pwub(o%ovn-hN;3d(y85{|?GVF+P<)`!C?9*8d5&4>PvBd-A`AjphHm zzgkSs*w!$9RQmpJA*T_8)*TC`AMZ9XUX(b+AS1Hre=FnR|5eOx44i)h|J{Bj_HX5j zR>lczDom?|cK-j&^pe5vPZ*ON^Rxdof7Smz`g--h7vl?ts{+Cda|M6=W8}#AH|L+; zztUe3|8D=@`QMLe7ULPtLdHfG=l{1qF8=TLERyllOG}2>A2a^7@?ZG>UAmazJh$Bc zl<$K7|9<)P-|L6m{~!O${;BY>G6?XTVA%aDmf_~NM+`UrEM}1XH|2i;^FoF!E@h@8 zEUHWnKX)>$|EBPd_dmzqA@?vf2Y5!{-?^D#ALy9 zg@KXH=>JsqZ4A?Y?)lgL^5!3r&&&VIuu3z8^X&Ln!TIMu$KMC%3 zGOqdmm!X>>gw2%s6H7m1z@It)H~c!ucu}6qh~;7ZE6H)|Uptc@ zbM~Jl423^D{^kE;X6)qn$Z(nC+y8rv)eN70*ZlAK@%{gYU+xUPjOu?kF`NGT&$5%r zo_+WKiA=G7JpL_Zh+(+MDD?l;-@E_T{WoI!%ru|blQHDm{X0YE zf5ZQ8*+Tza_^-^g=m#s)%wIWwpZt^h|DN?I({auN|1U7F{kP>G8`G)3CmHg7ar~?P z*Z9Ad<0ivi)~Ek({5APs_P_t%R7S!7;Y`vDPHe6JPO~QbU%+^WDVdRjaohiUe_j7_ zGCp9O#x#pL{okg4oBl@p_hiUpH2AOj&+(rmg9PI?=G%;${|Pe){WN2c%pYZ#pV{{3Ic5dXW0iSal0|4K%Of9o0C z8FT(GXHaFy{pbGw^Z$9@HJF_KW;4_=-2dnO=hgo+{|gvd7-un9{)+o|;@86ekN+=V zs`>B7D8o48|8}N0rd$7xGHv*O$DEyOUIPzERpTqw`&~Cne zKL4Hma{jMmyvE4O+{F0x|F=I={$Kd3`+p(hlRu093H)F7dkV7&!zHHeOj8;5{Fi0e z^gsFEGNyh1)-skb-~1QE;_-hgg9#%ag9n4jf5X3ZzsvvW|K7kP{QoWE0!HJ1UW}{% zW&RUqxc;a8-;)2f|MxKRGD$FUGG#J7{?E^>^}qc8F@`7qzB1M@<}(~)Q2L+EXv4UX zxtHDs?`hBJR|GZ-;W``^##|JUyCd4@^<{xRAyG5njt@b6#c zKSQQ1|4;uM2x1gwWMrsj==c7#;qZ zGYT>+`@7@+!@tJ=e>2QyQ29TBVcDN8|G59{{9E(4@89y@s*GlTjT!YAH2#S&iTppr z*vkC%-vy?Sf8YPl_^-zl_Hk(R9QxnK^np?S|8WM9e?9-6Gj;#(Vw%Os^52r-7~_V2M;K2t%=}}!lBG59dO{D1!6Mh46O1&pcxrZ9B={`ODnzw*D&jD`OL{zNls{?=qD zVtD*-#eV~a%l*9#>9V%nPVBgGDI_#Ge$7vF>U_G{r|`RM}JuvRG0+*i?hlxnlZ;Q zar}2;IP&-K|4R(t8Rjt6GPp4M{a?d4{of?U<$t&SmHp@Q=gj|A|E2!dGPM6=`}h67 z^S=OwivI}=KmWBe&j0Vpc$wkbe{+UZ2B-g;44eN2{uloj#PIR23u6oD)StgA|8xKU z#^A}+!=S|U?7sm+4deU&E=>Ilo($rQ3;w-job%6t>FmD~47303{?GA$&3|Kt=l|6h zOBl8@bTWMV-_F3pu%F@Xe{qIe|9lwU{=3b@#^Ax6_}}n<`oHOa?)?}3=fs%x--to^ z|AYS=|K%BL|MfDk|KtDf!m#)M2c`~&pG=DXl^HAl?fXBCVaNYBjG6zL88rU?{X6@A z3d4_oGnwcAbz=l>rVUH&g*jABUn`;T$Lc1^@3b@i3YEpU3d=PtCuWzaIbh{y)Od!PNZkFq7?H9fk;oi+}AI z1^&wZQ~B5PZ$Co?qYpzFL-yaYe@_1-8MqjqFic>W^^b!g@1HVbJwp@YH^x{-2gVwP zlMLVfw=(uHoML?YZ`yyuzaIYz895oBu$*H2%@Ff{^}pQz;*9)^<^R|JJNQrGuMG1& zhSf~dne6|uGQ|AP`oD+qB7-i&9ENQSTmG{!YBOZ~-k1|6TvJG5-9&_}?l9o%zOTL z{onup-rqM25`8qxS#z3{w9R88ZG!{byjX{r{ISn30WXDuV+9 z_y0ft6&V>B&6#@tNB>XzXZ1gTp@9)}rf|kz{(p!5sr^6BxPU>1(V5}R|MGwT|A+ov z{@;ipoI!&5BSQki?*BXfy=RDKJjS?}A@N_wzf=D-7;iA8F`i;j{oDI@<=;sRUl~G~ z8W@iJfB)Z_A&%iIW95HyhBg0X{)ID4|M%qoNyb{nLrlp`^8Zx+UH!fA?-qturb~>;DHZ^8e#t z@c1kAFO%Wu|4B@C46py4`4jf<<=@XtaZKS%ISgz5HT^xqP|B#wyovcM!~Xw!{>T4o zWDsGDVh~~a{mSgGym=RAIr$fSp5IuKdV13|GWPmVLHHcoZ-O#%)dGRum69+ zf^aPgnw-^>5?7I>vB@ zFaNU`8UOn--e-Ku(7|Z^|KwlQe}?}j{lCt{$8dom`QOZccK-w!4*a|EKkQ%kKLLiG z|G7*tjBgo@n0_-9G2UZT`G14q?!PbprZYtScleTJ9+jTogEvKZeoRQ+GWu<752|7r{`{&g`V|LbSC z@K2O+&OZf)Q~#L$&t%N~@4~d=zs`RxhS>krjN(ia{;yEEz_Gyb3c@6N!>Wc`nm@$EnP|8p2L{zd(N z^)K$f3u8RPY=*-Boc{~|b1`x=yk^k)|Ku-dw`Ck-4RhXq9|kUl1AlKY9r*u(q2<5g z|Ac?cjLR7={J+e&;m>h~@c*?8Doj`YssDfUxBBnz|GfV}=L%l>o6LCne>Kx|hD(gW z{~j_*F>Gd7%Jk{)MTR5)V;MFv9cMiJf9C&Ne=q%IVm$SagW=Xc<-cVNr3^ewSDCE- zTQe;BSN7kHDVRZ<(ei)Eznp*57}Nf}X6*bg@XwVo^q&&L-v3ws9{lI~f7gFqMkmIJ z|Cjz3Vi5X2h3WPGM+{&7`~6$MsKv04(TDNWzjObkK__Z4SulM1=ktHg-#P#4nJWJC zF&D%m3eJ z;9*eyx98u#zs>&_GE_3m_#eaY`QPsUw-~_>`gRpa1`OhBN;vSV9>7GW=!;`CI${)&JQ4M;X>KsQ&L|@L^2*AJ0_v|KLAS zMxnooOvnGPWZ+}c{J)LSiXrQN9K)}_Q~$mC=f`03{~Xhve<=*}820^*W}NeH*S`n< zBK{x$m&X*(;Ks0(A>!}z|4;w#`M-eS!~bLdbQnDt4l>0j`F zPe!@_F-$8Ng8xM`O!ync5XE$hF_o#9amGLI|0)dK|6eftVbJ)ukCBOC3&UEbJO9`i zcKwU|f8+lN#`*s_86N&?`FHIi+_Utqj@>r~lvlU;STB~2hRy%|nC$<}`&af~%Ss~s{gqR zCd^O%tzr~pVE^}%VF^R-|9#9m{(t;e&*1p`&c7sv;(zCu3>Z%Q{q|q^?}h)_Om2)@ z7{4%V`_um4`+xs`U)Fp7r!X!5Z~Q0x|CGP4{!M544o;1=f0O>pF(&?h!nB(y`tL6$ z>p$=RO<~Uar@_L?c=3+~!{0wI|2|^e{7>k=BxB2;#SC?fUqEAI4FCUpVr={SoVn7y|w~FlGMB_@~D3_HPp7AI7B& z-xyvnl>FPoz|Cm){|jU5Kga(O|JDCrVMtLBP&;LLFe*9nYub6>}#ejjGg^MBne-%T*zvB!J4D%TB7#{q8`j7Fy&wu0p zuNgo6XJS-h*!ySte}#Wx|4W&U|Ic8&`2W>^!T*;T9sk!bPG&gpPkYaq_~-Ed=RfxUJdA?>UH`@YKlHEle==(*LlE;6#%aH+87}|$9Rlo24 zTl>HCe=zI%|LF|1|BHX?GDI@0{IAQr>fgfu2mXKj$N7H+W7=PC#?$`~{XN8J^!LaA z9ENs=P)0>2?SD`HPyc`JUoCSDLlOfYL)M?1|BU~C|1V^i{4eI;wSPzcw=$YB-(i@< z^yq)?|JRI)|Kb@^7(D+fGj00+>%Tga%ztHuB@CMX5B{}d$oXx~V8|rO_?K0ZY1RJ( zhO2+X|IcFl{f~q3@4r{SGZ_W{)iC+6sQu?=w)4Wlo<{F9{T_E|FZw7jAD$Hf3Gvz|4IA* zg>m8En@sTx`~H4nVE8ZdcM|ivztd$mWga4EMeq=oHZ{B|{ruhFW z8G0Et{xvYf{`>p)_x~0D<}(;DS}{a2USVMSAI;?Z{~<#cquamy|DONn{!wB0|3CSk z7xSk7zZj-6efrD#-0DI8U6orFgW~^XVhU#XYgWL_P6Kn^#A7n_?bR4 z+5hkP|KpGQpVWWs%v1hPXAS$;@~`3Vk6-uyfBsj&%*2$=c<7(?D-YT zVE?D{pE`rk|Gx~Y8PXWO|NHds^}kI_6B#bBYX4{a&;DQR??a}GETK&9j9mYke+K{i z!0?jcHcJ7+gI|vSAN^eaUz)L=fs0Xt!GS^Z-yNn?e=jjLFqkl{WB9=E@-N?C$^T#e zM=@Ptsbz5czx3a`UolM1|6^D#Fz_%){b&1Y&v5j=9OG<;^^BAME@rs=@5i4iW&_4B zMtSxZzghn2{k!v>hxt0wHij$A|Nmb3Va2HQFO#{O?JvXKf64!E{Vr!nX0~B`%c#P@ z@#pj3;=e2Zg)!wbo@4&^zwZBxKYV{U{_8V-WUOZL{(tG${(lS%8B7ceQ49gglu(2>OFfuVPIR0f|xcHBOA%T&B;q8A0hL!&q7%ng{Fz7QeFiii?z%cDE1H-)k z3=9TL3=FFn7#M#3V_`j3I3laYZzg^7V-$v*~$*MAupUjJuc;9_K8FkxU|i2u*Pu!Vtvp@)HiA?yzW zL&zTnhTTjI44upj3?cs+80;7r7%nq1F!=pvU|8{=fx(@Tfr0x!1B1~&28PL?Q{fpw z_r)+UIQ?f}c=(S2be<8zb4CV+?*9x7>Hi?Kz<&mYK9GJ!28LKh28NCQ85n;5V_;bJ zkAXppfq@~25!AC|V0ihTfx(uEfuZd`1H3woF+kMs1>Iu61o4l}Zw4^f%FMv<{4WDT#~%g;0VW295C#T@ z=YJR&0vQ+>*q9g?{{CZNnDmc!5$;=E4@BT6{ocRq2zpqRT443~iFgX8XVA%YRfg$Zb14F}q z(7j#^3_eT@4Eq=v7)1UsFnk56XJ%kH$il$z5|sY_F)$osU|>l7$H4IYF9XARW(Ed7 zR!Dg6{=>jv!N9mMYJe=|bT>;FFt472_-Fq~mxU?^o^V3_-lf#Dt_1A`F@ z1A`7DBrW^?XJFXPz`*e4F9QSTUj~Lvj0_B485tOk{$^m%{KLSY$jrd-l9_>liIIUJ zf&p~O0|Uc}KMV|$|1vPd{9|B9VPIe=XJTNm{|^a2K4u06eP#v*t$z#*=b+)^`-g#{ z?iT|?H4_8F8WskIntu!oC4U$gE;2AM*fTLOnEqp6VEM`oq8=%f!Io%EZ7h=|3d>I506V_<-`uUj~Ms ze;F9AF)}bLWME*B`NP1l@IM2C77OS!LIwu+-wX^dKsR47F)+;j&%n_2kAb0y0dz|U z1B1&S28O6V3=DS63=FDlkg^EmmdF1X7&QJdFv$F4VEE4nDGxwp(xE>L3{L+b;dYgk zfuV+xf#Lff1_n>iDIQD=43GXYFw`?JFx0X#Fsx%>V37X9!0_Qe1H(y128QSV7#J@7 zV_C#iSQ!}FLH_#(DNpkl85lVK zF)&#EXJDxM$H2h(n}H$m4+Dc00|SFNDBUwNFxay&FwA9UU~vD>z%cO-q?}}8W?(q+ emx00f4+Fz>1_p-p{}~wK{xC2E{AXY|$Or&vonTe~ literal 0 HcmV?d00001 diff --git a/assets/sounds/default/togglebutton_unchecked.wav b/assets/sounds/default/togglebutton_unchecked.wav new file mode 100644 index 0000000000000000000000000000000000000000..3526ca0afeb1ea37b190577653af1cee5a4e3bb7 GIT binary patch literal 40364 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/embed.go b/assets/sounds/embed.go new file mode 100644 index 0000000..faa5fcf --- /dev/null +++ b/assets/sounds/embed.go @@ -0,0 +1,8 @@ +package sounds + +import "embed" + +// Default contains the bundled theme, independent of the working directory. +// +//go:embed default/*.wav +var Default embed.FS diff --git a/cmd/cthulhu-braille/main.go b/cmd/cthulhu-braille/main.go new file mode 100644 index 0000000..52fefa9 --- /dev/null +++ b/cmd/cthulhu-braille/main.go @@ -0,0 +1,33 @@ +// cthulhu-braille is the isolated BrlAPI helper and standalone diagnostic. +// The main executable starts it only when braille is enabled. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func main() { + if len(os.Args) > 2 || len(os.Args) == 2 && os.Args[1] != "--client" { + fmt.Fprintln(os.Stderr, "Usage: cthulhu-braille [--client]") + os.Exit(2) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if len(os.Args) == 2 { + if err := braille.RunClient(ctx, braille.NewRouting(), os.Stdin, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err) + os.Exit(1) + } + return + } + if err := braille.Diagnostic(ctx, braille.New(), os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/cthulhu/main.go b/cmd/cthulhu/main.go new file mode 100644 index 0000000..6366649 --- /dev/null +++ b/cmd/cthulhu/main.go @@ -0,0 +1,227 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "git.stormux.org/storm/cthulhu/internal/app" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/clipboard" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/logging" + "git.stormux.org/storm/cthulhu/internal/notificationui" + "git.stormux.org/storm/cthulhu/internal/ocrcapture" + "git.stormux.org/storm/cthulhu/internal/ocrconfirm" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/shortcutui" + "git.stormux.org/storm/cthulhu/internal/speechhistoryui" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/version" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "cthulhu: %v\n", err) + os.Exit(1) + } +} + +func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (result error) { + options := flag.NewFlagSet("cthulhu", flag.ContinueOnError) + options.SetOutput(stderr) + tentacleFile := options.String("tentacles", "", "explicit local tentacle launch list (experimental JSON)") + tentacleParent := options.String("tentacle-cgroup-parent", "auto", "delegated cgroup v2 parent for local tentacles, or auto") + noTentacles := options.Bool("no-tentacles", false, "start without tentacles, ignoring saved tentacles and the local launch list") + tentacleGuardian := options.String("tentacle-guardian", "", "run the internal tentacle lifetime helper") + showVersion := options.Bool("version", false, "print version and exit") + shortVersion := options.Bool("v", false, "print version and exit") + replace := options.Bool("replace", false, "stop the running Cthulhu instance before starting") + shortReplace := options.Bool("r", false, "stop the running Cthulhu instance before starting") + checkConfig := options.Bool("check-config", false, "validate configuration and exit") + shortSettings := options.Bool("s", false, "open the preferences window at startup") + settingsFlag := options.Bool("settings", false, "open the preferences window at startup") + preferencesUI := options.Bool("preferences-ui", false, "run the internal preferences helper (private pipe protocol)") + notificationListUI := options.Bool("notification-list-ui", false, "run the internal notification list helper (private pipe protocol)") + speechHistoryUI := options.Bool("speech-history-ui", false, "run the internal speech history helper (private pipe protocol)") + shortcutListUI := options.Bool("shortcut-list-ui", false, "run the internal shortcut list helper (private pipe protocol)") + clipboardRead := options.Bool("clipboard-read", false, "run the internal clipboard reader (private pipe protocol)") + clipboardWatch := options.Bool("clipboard-watch", false, "run the internal clipboard change helper (private pipe protocol)") + ocrCapture := options.Bool("ocr-capture", false, "run the internal OCR capture helper (private pipe protocol)") + ocrConfirm := options.Bool("ocr-confirm", false, "run the internal OCR click confirmation helper") + debugFile := options.String("debug-file", "", "enable debug logging and append logs only to this file") + speechSocket := options.String( + "speech-socket", + "", + "Speech Dispatcher SSIP socket (overrides SPEECHD_ADDRESS and SPEECHD_SOCKET)", + ) + configPath := options.String( + "config", + "", + "TOML configuration file (default: $XDG_DATA_HOME/cthulhu/user-settings-go.toml)", + ) + options.Usage = func() { + fmt.Fprintln(stdout, version.Details( + atspi.Version(), + os.Getenv("XDG_SESSION_TYPE"), + os.Getenv("XDG_SESSION_DESKTOP"), + )) + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "Usage: cthulhu [options]") + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "Options:") + // Helpers remain parseable but are not part of the public help. + visibleOptions := flag.NewFlagSet("cthulhu", flag.ContinueOnError) + visibleOptions.SetOutput(stderr) + options.VisitAll(func(option *flag.Flag) { + switch option.Name { + case "preferences-ui", "notification-list-ui", "speech-history-ui", "shortcut-list-ui", + "clipboard-read", "clipboard-watch", "ocr-capture", "ocr-confirm", "tentacle-guardian": + return + } + visibleOptions.Var(option.Value, option.Name, option.Usage) + visibleOptions.Lookup(option.Name).DefValue = option.DefValue + }) + visibleOptions.PrintDefaults() + } + + if err := options.Parse(arguments); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return fmt.Errorf("parse command line: %w", err) + } + if options.NArg() != 0 { + return fmt.Errorf("unexpected argument %q", options.Arg(0)) + } + if *showVersion || *shortVersion { + fmt.Fprintln(stdout, version.Details( + atspi.Version(), + os.Getenv("XDG_SESSION_TYPE"), + os.Getenv("XDG_SESSION_DESKTOP"), + )) + return nil + } + if *preferencesUI { + return preferences.Run(ctx, os.Stdin, stdout) + } + if *tentacleGuardian != "" { + stopInput := context.AfterFunc(ctx, func() { os.Stdin.Close() }) + defer stopInput() + return tentacle.RunGuardian(os.Stdin, stdout, *tentacleGuardian) + } + if *ocrConfirm { + return ocrconfirm.Run(os.Stdin, stdout) + } + if *notificationListUI { + return notificationui.Run(ctx, os.Stdin, stdout) + } + if *shortcutListUI { + return shortcutui.Run(os.Stdin) + } + if *speechHistoryUI { + return speechhistoryui.Run(ctx, os.Stdin, stdout) + } + if *clipboardRead { + return clipboard.WriteMessage(stdout) + } + if *clipboardWatch { + return clipboard.WatchText(ctx, os.Stdin, stdout) + } + if *ocrCapture { + return ocrcapture.Run(ctx, os.Stdin, stdout) + } + logger := slog.New(slog.DiscardHandler) + if *debugFile != "" { + fileWriter, err := logging.OpenFile(*debugFile, nil) + if err != nil { + return fmt.Errorf("open debug log: %w", err) + } + diagnostics := logging.NewAsync(fileWriter, logging.AsyncOptions{ + Options: logging.Options{Level: slog.LevelDebug, AddSource: true}, + }) + defer func() { + stopContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := diagnostics.Stop(stopContext); err != nil { + result = errors.Join(result, fmt.Errorf("stop diagnostics: %w", err)) + } + }() + logger = diagnostics.Logger() + } + logger.Info("starting Cthulhu", "version", version.String(), "pid", os.Getpid()) + settings, err := config.Load(*configPath) + if err != nil { + logger.Error("load configuration", "error", err) + return err + } + if *checkConfig { + if settings.Loaded && settings.ActiveProfile != "" { + fmt.Fprintf(stdout, "Configuration is valid; active profile: %s.\n", settings.ActiveProfile) + } else if settings.Loaded { + fmt.Fprintln(stdout, "Configuration is valid.") + } else { + fmt.Fprintln(stdout, "Configuration file not found; built-in defaults are valid.") + } + return nil + } + if *replace || *shortReplace { + if err := replaceRunning(ctx); err != nil { + return fmt.Errorf("replace running Cthulhu: %w", err) + } + } + + if settings.Loaded { + logger.Info("loaded configuration", "profile", settings.ActiveProfile, + "keyboard_layout", settings.KeyboardLayout, "cthulhu_modifier_keys", settings.CthulhuModifierKeys) + } else { + logger.Info("configuration not found; using defaults") + } + registryPath := tentacle.RegistryPath(settings.Path) + if *noTentacles { + *tentacleFile = "" + registryPath = "" + } + err = app.Run(ctx, app.Options{ + TentacleFile: *tentacleFile, + TentacleRegistry: registryPath, + TentacleCgroupParent: *tentacleParent, + EnableRemote: true, + OpenPreferences: *shortSettings || *settingsFlag, + Logger: logger, + SpeechSocket: *speechSocket, + Settings: &settings, + }) + if err != nil { + logger.Error("Cthulhu stopped with an error", "error", err) + } else { + logger.Info("Cthulhu stopped") + } + return err +} diff --git a/cmd/cthulhu/main_test.go b/cmd/cthulhu/main_test.go new file mode 100644 index 0000000..2c32094 --- /dev/null +++ b/cmd/cthulhu/main_test.go @@ -0,0 +1,227 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/version" +) + +func TestVersion(t *testing.T) { + t.Setenv("XDG_SESSION_TYPE", "x11") + t.Setenv("XDG_SESSION_DESKTOP", "GNOME") + want := version.Details("2.58.0", "x11", "GNOME") + for _, argument := range []string{"--version", "-v"} { + var stdout bytes.Buffer + if err := run(context.Background(), []string{argument}, &stdout, &stdout); err != nil { + t.Fatalf("run %s: %v", argument, err) + } + if got := strings.TrimSpace(stdout.String()); !strings.Contains(got, "Cthulhu screen reader version") || + !strings.Contains(got, "AT-SPI2 version:") || !strings.Contains(got, "Session: x11 GNOME") { + t.Fatalf("version output = %q, want details including %q", got, want) + } + } +} + +func TestHelp(t *testing.T) { + var versionOutput bytes.Buffer + if err := run(context.Background(), []string{"-v"}, &versionOutput, &versionOutput); err != nil { + t.Fatalf("run -v: %v", err) + } + for _, argument := range []string{"-h", "--help"} { + var stdout bytes.Buffer + if err := run(context.Background(), []string{argument}, &stdout, &stdout); err != nil { + t.Fatalf("run %s: %v", argument, err) + } + help := stdout.String() + if strings.Contains(help, "tentacle-guardian") { + t.Fatal("private guardian exposed in help") + } + if !strings.HasPrefix(help, versionOutput.String()+"\nUsage: cthulhu [options]\n\nOptions:\n") || + !strings.Contains(help, "-s") || !strings.Contains(help, "-settings") || + !strings.Contains(help, "-r") || !strings.Contains(help, "-replace") { + t.Fatalf("help output does not contain expected version, usage, and flags: %q", help) + } + } +} + +func TestCheckConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "user-settings.toml") + contents := []byte("[general]\nstartingProfile = [\"Default\", \"default\"]\n[profiles.default]\nspeakDescription = false\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + var stdout, stderr bytes.Buffer + if err := run( + context.Background(), + []string{"--check-config", "--config", path}, + &stdout, + &stderr, + ); err != nil { + t.Fatalf("run --check-config: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "active profile: default") { + t.Fatalf("check-config output = %q", got) + } + if stderr.Len() != 0 { + t.Fatalf("logging enabled without --debug-file: %q", stderr.String()) + } +} + +func TestCheckVersionedConfig(t *testing.T) { + for _, test := range []struct { + name string + contents string + wantError bool + }{ + {"current", "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\nenabled = false\n", false}, + {"future", "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n", true}, + {"invalid", "schema-version = 2\nactive-profile = 'default'\n[profiles.default.sound]\nvolume = 2.0\n", true}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + if err := os.WriteFile(path, []byte(test.contents), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + err := run(context.Background(), []string{"--check-config", "--config", path}, &stdout, &stderr) + if (err != nil) != test.wantError { + t.Fatalf("check-config error = %v, want error %v", err, test.wantError) + } + if !test.wantError && stdout.String() != "Configuration is valid; active profile: default.\n" { + t.Fatalf("check-config output = %q", stdout.String()) + } + if test.wantError && stdout.Len() != 0 { + t.Fatalf("invalid configuration reported success: %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("logging enabled without --debug-file: %q", stderr.String()) + } + }) + } +} + +func TestDebugFileCapturesStartupFailureAndAppends(t *testing.T) { + directory := t.TempDir() + logPath := filepath.Join(directory, "debug.log") + configPath := filepath.Join(directory, "settings.toml") + configContents := []byte("[profiles.default]\nenableSound = false\n") + if err := os.WriteFile(configPath, configContents, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + for range 2 { + var stdout, stderr bytes.Buffer + err := run(context.Background(), []string{ + "--debug-file", logPath, + "--config", configPath, + "--speech-socket", filepath.Join(directory, "absent.sock"), + }, &stdout, &stderr) + if err == nil { + t.Fatal("startup unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "connect to Speech Dispatcher") { + t.Fatalf("startup error = %v", err) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("debug-file wrote to terminal: stdout %q, stderr %q", stdout.String(), stderr.String()) + } + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(data), `"message":"starting Cthulhu"`) != 2 { + t.Fatalf("previous session was not retained: %s", data) + } + if strings.Count(string(data), `"message":"Cthulhu stopped with an error"`) != 2 { + t.Fatalf("startup failures were not logged: %s", data) + } + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + var record map[string]any + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatal(err) + } + if record["source"] == nil { + t.Fatalf("debug-file did not enable sources: %s", line) + } + if record["level"] == "ERROR" { + message, ok := record["error"].(string) + if !ok || !strings.Contains(message, "connect to Speech Dispatcher") { + t.Fatalf("missing actual error: %s", line) + } + } + } + info, err := os.Stat(logPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("log permissions = %o", info.Mode().Perm()) + } +} + +func TestDebugFileOpenFailure(t *testing.T) { + var output bytes.Buffer + err := run(context.Background(), []string{"--debug-file", t.TempDir()}, &output, &output) + if err == nil || !strings.Contains(err.Error(), "open debug log") { + t.Fatalf("error = %v", err) + } +} + +func TestDebugFileCheckConfig(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "debug.log") + configPath := filepath.Join(t.TempDir(), "absent.toml") + var stdout, stderr bytes.Buffer + if err := run(context.Background(), []string{ + "--check-config", "--config", configPath, "--debug-file", logPath, + }, &stdout, &stderr); err != nil { + t.Fatal(err) + } + if stdout.String() != "Configuration file not found; built-in defaults are valid.\n" { + t.Fatalf("check-config output = %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("debug-file wrote to terminal: %q", stderr.String()) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + var record map[string]any + if err := json.Unmarshal(bytes.TrimSpace(data), &record); err != nil { + t.Fatal(err) + } + if record["message"] != "starting Cthulhu" || record["source"] == nil { + t.Fatalf("missing startup diagnostic or source: %s", data) + } +} + +func TestStandaloneDebugFlagRejected(t *testing.T) { + var stdout, stderr bytes.Buffer + err := run(context.Background(), []string{"--debug"}, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -debug") { + t.Fatalf("standalone --debug error = %v", err) + } +} diff --git a/cmd/cthulhu/replace.go b/cmd/cthulhu/replace.go new file mode 100644 index 0000000..8f33ebe --- /dev/null +++ b/cmd/cthulhu/replace.go @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "git.stormux.org/storm/cthulhu/internal/remote" + "github.com/godbus/dbus/v5" +) + +const replaceWait = 8 * time.Second + +// replaceRunning identifies the active Go reader through its private session +// bus name. It never signals a process merely because its name matches. +func replaceRunning(ctx context.Context) error { + connection, err := dbus.ConnectSessionBus() + if err != nil { + return fmt.Errorf("connect to session bus: %w", err) + } + defer connection.Close() + owner, err := busNameOwner(ctx, connection) + if err != nil || owner == "" { + return err + } + var pid uint32 + if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetConnectionUnixProcessID", 0, owner).Store(&pid); err != nil { + return fmt.Errorf("get running reader PID: %w", err) + } + var uid uint32 + if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetConnectionUnixUser", 0, owner).Store(&uid); err != nil { + return fmt.Errorf("get running reader UID: %w", err) + } + if uid != uint32(os.Getuid()) || pid == 0 || int(pid) == os.Getpid() { + return errors.New("running reader has an unexpected owner or PID") + } + if err := verifyReaderExecutable(int(pid)); err != nil { + return err + } + _, started, err := processIdentity(int(pid)) + if err != nil { + return fmt.Errorf("inspect running reader identity: %w", err) + } + if current, err := busNameOwner(ctx, connection); err != nil || current != owner { + return fmt.Errorf("running reader changed before replacement: %w", err) + } + if err := syscall.Kill(int(pid), syscall.SIGTERM); err != nil { + return fmt.Errorf("stop running reader: %w", err) + } + if err := waitForProcessExit(ctx, int(pid), started, replaceWait); err == nil { + return nil + } else if !errors.Is(err, context.DeadlineExceeded) { + return err + } + // The old reader did not finish shutdown. Verify the PID still belongs to + // a reader before forcing it down. + if _, currentStart, err := processIdentity(int(pid)); errors.Is(err, os.ErrNotExist) || err == nil && currentStart != started { + return nil + } else if err != nil { + return err + } + if err := verifyReaderExecutable(int(pid)); err != nil { + return err + } + if err := syscall.Kill(int(pid), syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("force stop running reader: %w", err) + } + return waitForProcessExit(ctx, int(pid), started, 2*time.Second) +} + +func waitForProcessExit(ctx context.Context, pid int, started string, timeout time.Duration) error { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + state, currentStart, err := processIdentity(pid) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect running reader state: %w", err) + } + if state == "Z" || currentStart != started { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return context.DeadlineExceeded + case <-ticker.C: + } + } +} + +func processIdentity(pid int) (state, started string, err error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return "", "", err + } + closing := strings.LastIndexByte(string(data), ')') + if closing < 0 { + return "", "", errors.New("invalid process status") + } + fields := strings.Fields(string(data[closing+1:])) + if len(fields) <= 19 { + return "", "", errors.New("incomplete process status") + } + return fields[0], fields[19], nil +} + +func busNameOwner(ctx context.Context, connection *dbus.Conn) (string, error) { + var owner string + err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetNameOwner", 0, remote.BusName).Store(&owner) + if err == nil { + return owner, nil + } + var busError dbus.Error + if errors.As(err, &busError) && busError.Name == "org.freedesktop.DBus.Error.NameHasNoOwner" { + return "", nil + } + return "", fmt.Errorf("query running reader: %w", err) +} + +func verifyReaderExecutable(pid int) error { + running, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) + if err != nil { + return fmt.Errorf("inspect running reader: %w", err) + } + current, err := os.Executable() + if err != nil { + return fmt.Errorf("inspect current reader: %w", err) + } + running = strings.TrimSuffix(running, " (deleted)") + if filepath.Base(running) != filepath.Base(current) { + return fmt.Errorf("D-Bus owner is %q, not %q", running, filepath.Base(current)) + } + return nil +} diff --git a/cmd/cthulhu/replace_test.go b/cmd/cthulhu/replace_test.go new file mode 100644 index 0000000..d6bd84a --- /dev/null +++ b/cmd/cthulhu/replace_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/remote" + "github.com/godbus/dbus/v5" +) + +func TestReplaceHelper(t *testing.T) { + if os.Getenv("CTHULHU_REPLACE_HELPER") != "1" { + return + } + connection, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + reply, err := connection.RequestName(remote.BusName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + t.Fatalf("own reader bus name: %v, %v", reply, err) + } + fmt.Println("READY") + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM) + defer signal.Stop(signals) + <-signals +} + +func TestReplaceRunningNative(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_REPLACE") != "1" { + t.Skip("requires isolated session bus") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := replaceRunning(ctx); err != nil { + t.Fatalf("replace with no reader: %v", err) + } + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestReplaceHelper$") + command.Env = append(os.Environ(), "CTHULHU_REPLACE_HELPER=1") + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + if command.ProcessState == nil { + command.Process.Kill() + command.Wait() + } + }() + ready := make(chan bool, 1) + go func() { + scanner := bufio.NewScanner(output) + for scanner.Scan() { + if scanner.Text() == "READY" { + ready <- true + return + } + } + ready <- false + }() + select { + case started := <-ready: + if !started { + t.Fatal("helper did not claim bus name") + } + case <-ctx.Done(): + t.Fatal("helper did not claim bus name") + } + if err := replaceRunning(ctx); err != nil { + t.Fatal(err) + } + if err := command.Wait(); err != nil { + t.Fatalf("running reader did not exit cleanly: %v", err) + } + if err := replaceRunning(ctx); err != nil { + t.Fatalf("replace after reader exit: %v", err) + } +} diff --git a/cmd/parityledger/main.go b/cmd/parityledger/main.go new file mode 100644 index 0000000..2ac1a37 --- /dev/null +++ b/cmd/parityledger/main.go @@ -0,0 +1,65 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package main + +import ( + "fmt" + "io" + "os" + + "git.stormux.org/storm/cthulhu/internal/parity" +) + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "parityledger: %v\n", err) + os.Exit(1) + } +} + +func run(arguments []string, stdout, stderr io.Writer) error { + if len(arguments) == 1 && (arguments[0] == "-h" || arguments[0] == "--help") { + writeUsage(stdout) + return nil + } + if len(arguments) != 2 || arguments[0] != "validate" { + writeUsage(stderr) + return fmt.Errorf("expected validate LEDGER") + } + + file, err := os.Open(arguments[1]) + if err != nil { + return fmt.Errorf("open parity ledger: %w", err) + } + defer file.Close() + + records, err := parity.Load(file) + if err != nil { + return fmt.Errorf("validate parity ledger: %w", err) + } + fmt.Fprintf(stdout, "Validated %d parity ledger rows.\n", len(records)) + return nil +} + +func writeUsage(writer io.Writer) { + fmt.Fprintln(writer, "Usage: parityledger validate LEDGER") + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Validates the schema, required fields, statuses, and duplicate row IDs.") + fmt.Fprintln(writer) + fmt.Fprintf(writer, "Allowed statuses: %s.\n", parity.FormatStatuses()) +} diff --git a/cmd/parityledger/main_test.go b/cmd/parityledger/main_test.go new file mode 100644 index 0000000..f07f690 --- /dev/null +++ b/cmd/parityledger/main_test.go @@ -0,0 +1,47 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package main + +import ( + "bytes" + "path/filepath" + "strings" + "testing" +) + +func TestValidateFixture(t *testing.T) { + var stdout bytes.Buffer + ledgerPath := filepath.Join("..", "..", "testdata", "parity-ledger.csv") + if err := run([]string{"validate", ledgerPath}, &stdout, &stdout); err != nil { + t.Fatalf("validate fixture: %v", err) + } + if !strings.HasPrefix(stdout.String(), "Validated ") || + !strings.Contains(stdout.String(), " parity ledger rows.") { + t.Fatalf("unexpected validation output: %q", stdout.String()) + } +} + +func TestUsage(t *testing.T) { + var stdout bytes.Buffer + if err := run([]string{"--help"}, &stdout, &stdout); err != nil { + t.Fatalf("run --help: %v", err) + } + if !strings.Contains(stdout.String(), "Usage: parityledger validate LEDGER") { + t.Fatalf("unexpected usage output: %q", stdout.String()) + } +} diff --git a/cmd/tentacle-host/main.go b/cmd/tentacle-host/main.go new file mode 100644 index 0000000..edc0a3c --- /dev/null +++ b/cmd/tentacle-host/main.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// A host diagnostic tool for external Tentacles, independent of the running reader. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() (runError error) { + options := flag.NewFlagSet("host", flag.ContinueOnError) + cgroupParent := options.String("cgroup-parent", "", "delegated cgroup v2 parent for resource containment") + presentation := options.Bool("presentation", false, "grant command speech/braille result capability (prints JSON only)") + interactive := options.Bool("interaction", false, "grant internal form/clipboard/focused-item capability (prints initial JSON only)") + command := options.String("command", "echo", "registered command to invoke") + if err := options.Parse(os.Args[1:]); err != nil { + return err + } + if options.NArg() < 1 { + return fmt.Errorf("usage: host [-cgroup-parent /delegated/parent] /absolute/program [arguments...]") + } + var manager *tentacle.Manager + var err error + if *cgroupParent == "" { + manager, err = tentacle.NewManager(tentacle.DefaultLimits(), nil) + } else { + manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, *cgroupParent, tentacle.DefaultResourceLimits()) + } + if err != nil { + return err + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + runError = errors.Join(runError, manager.Shutdown(ctx)) + }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := manager.Start(ctx, tentacle.Spec{ID: "local-example", Program: options.Args(), Presentation: *presentation, Interaction: *interactive}) + if err != nil { + return err + } + select { + case <-session.Ready(): + case <-ctx.Done(): + return ctx.Err() + } + if state, err := session.Status(); state != tentacle.Ready { + return fmt.Errorf("startup %s: %v", state, err) + } + if err := session.Activate(); err != nil { + return err + } + pending, err := session.Invoke(ctx, *command, json.RawMessage(`{"text":"Hello, é猫😀é"}`)) + if err != nil { + return err + } + select { + case result := <-pending: + if result.Err != nil { + return result.Err + } + fmt.Println(string(result.Value)) + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/distro-packages/Arch-Linux/cthulhu-git/.SRCINFO b/distro-packages/Arch-Linux/cthulhu-git/.SRCINFO new file mode 100644 index 0000000..93f9ba3 --- /dev/null +++ b/distro-packages/Arch-Linux/cthulhu-git/.SRCINFO @@ -0,0 +1,42 @@ +pkgbase = cthulhu-git + pkgdesc = Desktop-agnostic screen reader with speech, Braille, and Tentacle support + pkgver = 2026.10.12.r1.ga998fe6 + pkgrel = 1 + url = https://git.stormux.org/storm/cthulhu + arch = x86_64 + arch = aarch64 + arch = armv7h + license = GPL-3.0-or-later + makedepends = git + makedepends = go + makedepends = pkgconf + depends = glibc + depends = at-spi2-core + depends = glib2 + depends = gdk-pixbuf2 + depends = gtk3 + depends = speech-dispatcher + depends = gstreamer + depends = gst-plugins-base + depends = gst-plugins-good + depends = brltty + depends = liblouis + depends = libx11 + depends = libxtst + depends = libxfixes + depends = libxkbcommon + depends = xclip + depends = dbus + depends = systemd + optdepends = git: Discover and update Tentacle repositories + optdepends = tesseract: OCR engine for text recognition + optdepends = tesseract-data-eng: English OCR language data + provides = cthulhu=2026.10.12.r1.ga998fe6 + conflicts = cthulhu + options = !debug + options = !lto + source = cthulhu::git+https://git.stormux.org/storm/cthulhu.git#branch=master + sha512sums = SKIP + optdepends_x86_64 = cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration + +pkgname = cthulhu-git diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index d68d34f..c3d7b03 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,21 +4,20 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.09.03.r475.gd4bec38 +pkgver=2026.10.12.r1.ga998fe6 pkgrel=1 -pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" +pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" arch=(x86_64 aarch64 armv7h) -license=(LGPL-2.1-or-later) +license=(GPL-3.0-or-later) provides=("${_pkgname}=${pkgver}") conflicts=("${_pkgname}") depends=( - # Core AT-SPI accessibility + # Native accessibility and user interface + glibc at-spi2-core glib2 - gobject-introspection-runtime - python-gobject - python-cairo + gdk-pixbuf2 gtk3 # Audio and speech speech-dispatcher @@ -27,85 +26,84 @@ depends=( gst-plugins-good # pulsesink, more decoders # Braille support - python-brltty + brltty liblouis - # Plugin system and D-Bus remote control - python-pluggy - python-tomlkit - python-dasbus - python-msgpack - python-tornado - python-xlib + # Xorg input, window capture, and clipboard operations + libx11 + libxtst + libxfixes + libxkbcommon + xclip - # Desktop integration - hicolor-icon-theme - libwnck3 - pango - - # System utilities - python-setproctitle - xorg-xkbcomp + # Private D-Bus services and contained Tentacle processes + dbus + systemd ) optdepends=( - 'git: Install and update third-party plugin sources' - 'python-pywayland: Wayland shared workspace backend' - 'piper-tts: Piper speech backend' - 'socat: Send text to the Self Voice socket from the command line' - - # AI Assistant providers (optional) - 'python-requests: AI Assistant plugin and HTTP provider support' - 'python-pyautogui: AI Assistant mouse and keyboard actions' - 'claude-code: Claude AI provider support' - 'openai-codex: OpenAI Codex provider support' - 'gemini-cli: Gemini AI provider support' - 'ollama: Local AI model support' - - # OCR plugin dependencies (optional) - 'python-pillow: Image processing for OCR' - 'python-pytesseract: Python wrapper for Tesseract OCR engine' + 'git: Discover and update Tentacle repositories' 'tesseract: OCR engine for text recognition' + 'tesseract-data-eng: English OCR language data' ) optdepends_x86_64=( - # The helper is x86_64-only, but this package is architecture-independent. - 'cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration' + # Wine accessibility helpers are supported on x86_64. + 'wine-access: Wine, Proton, NVDA Controller, and Tolk integration' ) makedepends=( git - meson + go + pkgconf ) +options=(!debug !lto) source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master") sha512sums=('SKIP') +prepare() { + cd "${_pkgname}" + git checkout testing +} + pkgver() { cd "${_pkgname}" local projectVersion revisionCount commitHash - projectVersion=$(sed -n "s/^[[:space:]]*version: '\([^']*\)'.*/\1/p" meson.build) - projectVersion=${projectVersion%-master} - projectVersion=${projectVersion//-/.} + projectVersion=$(sed -n 's/^const Version = "\([^"]*\)"/\1/p' internal/version/version.go) [[ -n "${projectVersion}" ]] || return 1 revisionCount=$(git rev-list --count HEAD) commitHash=$(git rev-parse --short=7 HEAD) - printf "%s.r%s.g%s\n" "${projectVersion}" "${revisionCount}" "${commitHash}" + echo "${projectVersion}.r${revisionCount}.g${commitHash}" } build() { cd "${_pkgname}" - arch-meson _build -Dwine-access=disabled - meson compile -C _build + export CGO_ENABLED=1 + export CGO_CPPFLAGS="${CPPFLAGS}" + export CGO_CFLAGS="${CFLAGS}" + export CGO_CXXFLAGS="${CXXFLAGS}" + export CGO_LDFLAGS="${LDFLAGS}" + export GOPATH="${srcdir}/go" + export GOCACHE="${GOCACHE:-${srcdir}/go-build}" + export GOFLAGS="-buildmode=pie -trimpath -ldflags=-linkmode=external -mod=readonly -modcacherw" + + go build -o build/cthulhu ./cmd/cthulhu + go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille +} + +check() { + cd "${_pkgname}" + export CGO_ENABLED=1 + export GOPATH="${srcdir}/go" + export GOCACHE="${GOCACHE:-${srcdir}/go-build}" + go test -tags brlapi ./... -count=1 } package() { - arch=(any) - provides=("cthulhu=${pkgver}") - conflicts=(cthulhu) cd "${_pkgname}" - meson install -C _build --destdir "$pkgdir" - - # Remove icon cache - it will be generated by post-install hooks - rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache" + install -Dm755 build/cthulhu "${pkgdir}/usr/bin/cthulhu" + install -Dm755 build/cthulhu-braille "${pkgdir}/usr/bin/cthulhu-braille" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 AUTHORS "${pkgdir}/usr/share/doc/${pkgname}/AUTHORS" } # vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD deleted file mode 100644 index a01c80d..0000000 --- a/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD +++ /dev/null @@ -1,57 +0,0 @@ -# Maintainer: Storm Dragon -# shellcheck shell=bash disable=SC2034,SC2154,SC2164 - -pkgname=cthulhu-wine-access-git -_pkgname=cthulhu -pkgver=2026.09.03.r475.gd4bec38 -pkgrel=1 -pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu" -url="https://git.stormux.org/storm/cthulhu" -arch=(x86_64) -license=(LGPL-2.1-or-later) -depends=( - glib2 - wine -) -makedepends=( - cmake - git -) -provides=(cthulhu-wine-access) -conflicts=(cthulhu-wine-access) -source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master") -sha512sums=('SKIP') -options=(!lto) - -pkgver() { - cd "${_pkgname}" - local projectVersion revisionCount commitHash - - projectVersion=$(sed -n "s/^[[:space:]]*version: '\([^']*\)'.*/\1/p" meson.build) - projectVersion=${projectVersion%-master} - projectVersion=${projectVersion//-/.} - [[ -n "${projectVersion}" ]] || return 1 - - revisionCount=$(git rev-list --count HEAD) - commitHash=$(git rev-parse --short=7 HEAD) - printf "%s.r%s.g%s\n" "${projectVersion}" "${revisionCount}" "${commitHash}" -} - -build() { - local sourceDir="${srcdir}/${_pkgname}" - bash "${sourceDir}/scripts/build-wine-access.sh" \ - "${sourceDir}/wine-access" \ - "${sourceDir}/_build/wine-access-build" \ - "${sourceDir}/_build/cthulhu-wine-access.exe" \ - "${sourceDir}/_build/cthulhu-wine-access.exe.so" -} - -package() { - cd "${_pkgname}" - install -Dm755 _build/cthulhu-wine-access.exe \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" - install -Dm755 _build/cthulhu-wine-access.exe.so \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" -} - -# vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD deleted file mode 100644 index 8490255..0000000 --- a/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD +++ /dev/null @@ -1,40 +0,0 @@ -# Maintainer: Storm Dragon -# shellcheck shell=bash disable=SC2034,SC2154,SC2164 - -pkgname=cthulhu-wine-access -pkgver=2026.09.09 -pkgrel=1 -pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu" -url="https://git.stormux.org/storm/cthulhu" -arch=(x86_64) -license=(LGPL-2.1-or-later) -depends=( - glib2 - wine -) -makedepends=( - cmake - git -) -source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}") -sha512sums=('SKIP') -options=(!lto !debug) - -build() { - local sourceDir="${srcdir}/cthulhu" - bash "${sourceDir}/scripts/build-wine-access.sh" \ - "${sourceDir}/wine-access" \ - "${sourceDir}/_build/wine-access-build" \ - "${sourceDir}/_build/cthulhu-wine-access.exe" \ - "${sourceDir}/_build/cthulhu-wine-access.exe.so" -} - -package() { - cd cthulhu - install -Dm755 _build/cthulhu-wine-access.exe \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" - install -Dm755 _build/cthulhu-wine-access.exe.so \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" -} - -# vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD deleted file mode 100644 index 024f61f..0000000 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ /dev/null @@ -1,93 +0,0 @@ -# Maintainer: Storm Dragon -# shellcheck shell=bash disable=SC2034,SC2154,SC2164 - -pkgbase=cthulhu -pkgname=cthulhu -pkgver=2026.09.09 -pkgrel=1 -pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" -url="https://git.stormux.org/storm/cthulhu" -arch=(x86_64 aarch64 armv7h) -license=(LGPL-2.1-or-later) -depends=( - # Core AT-SPI accessibility - at-spi2-core - glib2 - gobject-introspection-runtime - python-gobject - python-cairo - gtk3 - # Audio and speech - speech-dispatcher - gstreamer - gst-plugins-base # playbin, audiotestsrc, basic decoders - gst-plugins-good # pulsesink, more decoders - - # Braille support - python-brltty - liblouis - - # Plugin system and D-Bus remote control - python-pluggy - python-tomlkit - python-dasbus - python-msgpack - python-tornado - python-xlib - - # Desktop integration - hicolor-icon-theme - libwnck3 - pango - - # System utilities - python-setproctitle - xorg-xkbcomp -) -optdepends=( - 'git: Install and update third-party plugin sources' - 'python-pywayland: Wayland shared workspace backend' - 'piper-tts: Piper speech backend' - 'socat: Send text to the Self Voice socket from the command line' - - # AI Assistant providers (optional) - 'python-requests: AI Assistant plugin and HTTP provider support' - 'python-pyautogui: AI Assistant mouse and keyboard actions' - 'claude-code: Claude AI provider support' - 'openai-codex: OpenAI Codex provider support' - 'gemini-cli: Gemini AI provider support' - 'ollama: Local AI model support' - - # OCR plugin dependencies (optional) - 'python-pillow: Image processing for OCR' - 'python-pytesseract: Python wrapper for Tesseract OCR engine' - 'tesseract: OCR engine for text recognition' -) -optdepends_x86_64=( - 'cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration' -) -makedepends=( - git - meson -) -source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}") -sha512sums=('SKIP') - -build() { - cd cthulhu - arch-meson _build -Dwine-access=disabled - meson compile -C _build -} - -package() { - arch=(any) - provides=("cthulhu=${pkgver}") - conflicts=(cthulhu) - cd cthulhu - meson install -C _build --destdir "$pkgdir" - - # Remove icon cache - it will be generated by post-install hooks - rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache" -} - -# vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Slackware/cthulhu.SlackBuild b/distro-packages/Slackware/cthulhu.SlackBuild deleted file mode 100755 index 1d46689..0000000 --- a/distro-packages/Slackware/cthulhu.SlackBuild +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/bash - -# Slackware build script for cthulhu -# Created based on PKGBUILD from Storm Dragon - -cd "$(dirname "$0")" ; CWD=$(pwd) - -PRGNAM=cthulhu -VERSION=${VERSION:-2026.09.09} -BUILD=${BUILD:-1} -TAG=storm -PKGTYPE=txz - -if [ -z "${ARCH:-}" ]; then - case "$( uname -m )" in - i?86) ARCH=i586 ;; - arm*) ARCH=arm ;; - *) ARCH=$( uname -m ) ;; - esac -fi - -# If the variable PRINT_PACKAGE_NAME is set, then this script will report what -# the name of the created package would be, and then exit. This information -# could be useful to other scripts. -if [ -n "${PRINT_PACKAGE_NAME:-}" ]; then - echo "$PRGNAM-$VERSION-$ARCH-$BUILD$TAG.$PKGTYPE" - exit 0 -fi - -TMP=$CWD -PKG=$TMP/package-$PRGNAM -OUTPUT=$CWD - -if [ "$ARCH" = "i586" ]; then - SLKCFLAGS="-O2 -march=i586 -mtune=i686" - LIBDIRSUFFIX="" -elif [ "$ARCH" = "i686" ]; then - SLKCFLAGS="-O2 -march=i686 -mtune=i686" - LIBDIRSUFFIX="" -elif [ "$ARCH" = "x86_64" ]; then - SLKCFLAGS="-O2 -fPIC" - LIBDIRSUFFIX="64" -else - SLKCFLAGS="-O2" - LIBDIRSUFFIX="" -fi - -set -e - -rm -rf "$PKG" -mkdir -p "$TMP" "$PKG" "$OUTPUT" -cd "$TMP" -rm -rf "$PRGNAM-$VERSION" -git clone --branch "$VERSION" --depth 1 https://git.stormux.org/storm/cthulhu.git "$PRGNAM-$VERSION" -cd "$PRGNAM-$VERSION" -chown -R root:root . -find -L . \ - \( -perm 777 -o -perm 775 -o -perm 750 -o -perm 711 -o -perm 555 \ - -o -perm 511 \) -exec chmod 755 {} \; -o \ - \( -perm 666 -o -perm 664 -o -perm 640 -o -perm 600 -o -perm 444 \ - -o -perm 440 -o -perm 400 \) -exec chmod 644 {} \; - -CFLAGS="$SLKCFLAGS" \ -CXXFLAGS="$SLKCFLAGS" \ -meson setup _build \ - --prefix=/usr \ - --libdir="/usr/lib${LIBDIRSUFFIX}" \ - --sysconfdir=/etc \ - --localstatedir=/var \ - --mandir=/usr/man \ - --buildtype=release -meson compile -C _build -DESTDIR="$PKG" meson install -C _build - -find "$PKG" -print0 | xargs -0 file | grep -e "executable" -e "shared object" | grep ELF \ - | cut -f 1 -d : | xargs strip --strip-unneeded 2> /dev/null || true - -rm -f "$PKG/usr/share/icons/hicolor/icon-theme.cache" - -mkdir -p "$PKG/usr/doc/$PRGNAM-$VERSION" -cp -a COPYING HACKING README.md README-DEVELOPMENT.md RELEASE-HOWTO \ - "$PKG/usr/doc/$PRGNAM-$VERSION" -cat "$CWD/$PRGNAM.SlackBuild" > "$PKG/usr/doc/$PRGNAM-$VERSION/$PRGNAM.SlackBuild" - -mkdir -p "$PKG/install" -cat "$CWD/slack-desc" > "$PKG/install/slack-desc" -cat "$CWD/doinst.sh" > "$PKG/install/doinst.sh" - -cd "$PKG" -/sbin/makepkg -l y -c n "$OUTPUT/$PRGNAM-$VERSION-$ARCH-$BUILD$TAG.$PKGTYPE" diff --git a/distro-packages/Slackware/doinst.sh b/distro-packages/Slackware/doinst.sh deleted file mode 100644 index 00d4b61..0000000 --- a/distro-packages/Slackware/doinst.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -if [ -x /usr/bin/update-desktop-database ]; then - /usr/bin/update-desktop-database -q usr/share/applications >/dev/null 2>&1 -fi - -if [ -e usr/share/icons/hicolor/icon-theme.cache ]; then - if [ -x /usr/bin/gtk-update-icon-cache ]; then - /usr/bin/gtk-update-icon-cache usr/share/icons/hicolor >/dev/null 2>&1 - fi -fi diff --git a/distro-packages/Slackware/slack-desc b/distro-packages/Slackware/slack-desc deleted file mode 100644 index d420716..0000000 --- a/distro-packages/Slackware/slack-desc +++ /dev/null @@ -1,19 +0,0 @@ -# HOW TO EDIT THIS FILE: -# The "handy ruler" below makes it easier to edit a package description. -# Line up the first '|' above the ':' following the base package name, and -# the '|' on the right side marks the last column you can put a character in. -# You must make exactly 11 lines for the formatting to be correct. It's also -# customary to leave one space after the ':' except on otherwise blank lines. - - |-----handy-ruler------------------------------------------------------| -cthulhu: cthulhu (Screen reader for blind or visually impaired users) -cthulhu: -cthulhu: Cthulhu is a screen reader for individuals who are blind or visually -cthulhu: impaired, forked from Orca. It provides a way to access applications -cthulhu: and toolkits that support the AT-SPI accessibility infrastructure. -cthulhu: -cthulhu: Homepage: https://git.stormux.org/storm/cthulhu -cthulhu: -cthulhu: -cthulhu: -cthulhu: diff --git a/distro-packages/Slint/README b/distro-packages/Slint/README deleted file mode 100644 index 241befc..0000000 --- a/distro-packages/Slint/README +++ /dev/null @@ -1,37 +0,0 @@ -Cthulhu is a screen reader for individuals who are blind or visually impaired, -forked from Orca. It provides access to applications and toolkits that support -the AT-SPI (e.g., GNOME and other Linux desktop environments). - -This fork is currently maintained by Storm Dragon. It builds on upstream Orca -work, but former Orca maintainers and contributors are not current maintainers -of this fork. - -This screen reader helps users navigate their desktop environment and applications -through speech synthesis and braille output. - -After installation, you can start Cthulhu through your desktop environment -or by running 'cthulhu' from the command line. - -DEPENDENCIES: -This package requires the following packages, all available from SlackBuilds.org: -- at-spi2-core -- brltty -- gobject-introspection -- gstreamer -- gst-plugins-base -- gst-plugins-good -- gtk3 -- liblouis -- libwnck3 -- python3-cairo -- python3-dasbus -- python3-gobject -- python3-pluggy -- python3-pywayland -- python3-setproctitle -- python3-tomlkit -- speech-dispatcher - -BUILD DEPENDENCIES: -- meson -- ninja diff --git a/distro-packages/Slint/cthulhu-info b/distro-packages/Slint/cthulhu-info deleted file mode 100644 index f61157a..0000000 --- a/distro-packages/Slint/cthulhu-info +++ /dev/null @@ -1,10 +0,0 @@ -PRGNAM="cthulhu" -VERSION="2026.09.09" -HOMEPAGE="https://git.stormux.org/storm/cthulhu" -DOWNLOAD="https://git.stormux.org/storm/cthulhu.git" -MD5SUM="SKIP" -DOWNLOAD_x86_64="" -MD5SUM_x86_64="" -REQUIRES="at-spi2-core brltty gobject-introspection gstreamer gst-plugins-base gst-plugins-good gtk3 liblouis libwnck3 python3-cairo python3-dasbus python3-gobject python3-pluggy python3-pywayland python3-setproctitle python3-tomlkit speech-dispatcher" -MAINTAINER="Storm Dragon" -EMAIL="storm_dragon@stormux.org" diff --git a/distro-packages/Slint/cthulhu.SlackBuild b/distro-packages/Slint/cthulhu.SlackBuild deleted file mode 100755 index 1d46689..0000000 --- a/distro-packages/Slint/cthulhu.SlackBuild +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/bash - -# Slackware build script for cthulhu -# Created based on PKGBUILD from Storm Dragon - -cd "$(dirname "$0")" ; CWD=$(pwd) - -PRGNAM=cthulhu -VERSION=${VERSION:-2026.09.09} -BUILD=${BUILD:-1} -TAG=storm -PKGTYPE=txz - -if [ -z "${ARCH:-}" ]; then - case "$( uname -m )" in - i?86) ARCH=i586 ;; - arm*) ARCH=arm ;; - *) ARCH=$( uname -m ) ;; - esac -fi - -# If the variable PRINT_PACKAGE_NAME is set, then this script will report what -# the name of the created package would be, and then exit. This information -# could be useful to other scripts. -if [ -n "${PRINT_PACKAGE_NAME:-}" ]; then - echo "$PRGNAM-$VERSION-$ARCH-$BUILD$TAG.$PKGTYPE" - exit 0 -fi - -TMP=$CWD -PKG=$TMP/package-$PRGNAM -OUTPUT=$CWD - -if [ "$ARCH" = "i586" ]; then - SLKCFLAGS="-O2 -march=i586 -mtune=i686" - LIBDIRSUFFIX="" -elif [ "$ARCH" = "i686" ]; then - SLKCFLAGS="-O2 -march=i686 -mtune=i686" - LIBDIRSUFFIX="" -elif [ "$ARCH" = "x86_64" ]; then - SLKCFLAGS="-O2 -fPIC" - LIBDIRSUFFIX="64" -else - SLKCFLAGS="-O2" - LIBDIRSUFFIX="" -fi - -set -e - -rm -rf "$PKG" -mkdir -p "$TMP" "$PKG" "$OUTPUT" -cd "$TMP" -rm -rf "$PRGNAM-$VERSION" -git clone --branch "$VERSION" --depth 1 https://git.stormux.org/storm/cthulhu.git "$PRGNAM-$VERSION" -cd "$PRGNAM-$VERSION" -chown -R root:root . -find -L . \ - \( -perm 777 -o -perm 775 -o -perm 750 -o -perm 711 -o -perm 555 \ - -o -perm 511 \) -exec chmod 755 {} \; -o \ - \( -perm 666 -o -perm 664 -o -perm 640 -o -perm 600 -o -perm 444 \ - -o -perm 440 -o -perm 400 \) -exec chmod 644 {} \; - -CFLAGS="$SLKCFLAGS" \ -CXXFLAGS="$SLKCFLAGS" \ -meson setup _build \ - --prefix=/usr \ - --libdir="/usr/lib${LIBDIRSUFFIX}" \ - --sysconfdir=/etc \ - --localstatedir=/var \ - --mandir=/usr/man \ - --buildtype=release -meson compile -C _build -DESTDIR="$PKG" meson install -C _build - -find "$PKG" -print0 | xargs -0 file | grep -e "executable" -e "shared object" | grep ELF \ - | cut -f 1 -d : | xargs strip --strip-unneeded 2> /dev/null || true - -rm -f "$PKG/usr/share/icons/hicolor/icon-theme.cache" - -mkdir -p "$PKG/usr/doc/$PRGNAM-$VERSION" -cp -a COPYING HACKING README.md README-DEVELOPMENT.md RELEASE-HOWTO \ - "$PKG/usr/doc/$PRGNAM-$VERSION" -cat "$CWD/$PRGNAM.SlackBuild" > "$PKG/usr/doc/$PRGNAM-$VERSION/$PRGNAM.SlackBuild" - -mkdir -p "$PKG/install" -cat "$CWD/slack-desc" > "$PKG/install/slack-desc" -cat "$CWD/doinst.sh" > "$PKG/install/doinst.sh" - -cd "$PKG" -/sbin/makepkg -l y -c n "$OUTPUT/$PRGNAM-$VERSION-$ARCH-$BUILD$TAG.$PKGTYPE" diff --git a/distro-packages/Slint/doinst.sh b/distro-packages/Slint/doinst.sh deleted file mode 100644 index 00d4b61..0000000 --- a/distro-packages/Slint/doinst.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -if [ -x /usr/bin/update-desktop-database ]; then - /usr/bin/update-desktop-database -q usr/share/applications >/dev/null 2>&1 -fi - -if [ -e usr/share/icons/hicolor/icon-theme.cache ]; then - if [ -x /usr/bin/gtk-update-icon-cache ]; then - /usr/bin/gtk-update-icon-cache usr/share/icons/hicolor >/dev/null 2>&1 - fi -fi diff --git a/distro-packages/Slint/slack-desc b/distro-packages/Slint/slack-desc deleted file mode 100644 index 3f09536..0000000 --- a/distro-packages/Slint/slack-desc +++ /dev/null @@ -1,19 +0,0 @@ -# HOW TO EDIT THIS FILE: -# The "handy ruler" below makes it easier to edit a package description. -# Line up the first '|' above the ':' following the base package name, and -# the '|' on the right side marks the last column you can put a character in. -# You must make exactly 11 lines for the formatting to be correct. It's also -# customary to leave one space after the ':' except on otherwise blank lines. - - |-----handy-ruler------------------------------------------------------| -cthulhu: cthulhu (Screen reader for blind or visually impaired users) -cthulhu: -cthulhu: Cthulhu is a screen reader for individuals who are blind or visually -cthulhu: impaired, forked from Orca. It provides a way to access applications -cthulhu: and toolkits that support the AT-SPI (e.g., the GNOME desktop). -cthulhu: -cthulhu: Homepage: https://git.stormux.org/storm/cthulhu -cthulhu: -cthulhu: -cthulhu: -cthulhu: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..45dad23 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module git.stormux.org/storm/cthulhu + +go 1.24.0 + +require github.com/pelletier/go-toml/v2 v2.4.3 + +require github.com/godbus/dbus/v5 v5.1.0 + +require github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6bfd03e --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..64f3be9 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,371 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "errors" + "log/slog" + "math/rand/v2" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/lifecycle" + "git.stormux.org/storm/cthulhu/internal/logging" + "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/sound" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/wine" +) + +type Speaker interface { + Connect(context.Context) error + Speak(context.Context, string) error + Cancel(context.Context) error + Close(context.Context) error +} + +type Adapter interface { + Events() <-chan atspi.Event + Overflow() uint64 + CaptureDesktop(context.Context) (*atspi.AccessibleTree, error) + CaptureReviewWindow(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) + CaptureDocument(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) + CaptureDocumentText(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) + Start(context.Context) error + Stop(context.Context) error +} + +type speechEnqueuer interface { + Enqueue(speechRequest) + Interrupt() +} + +type soundService interface { + Start(context.Context) error + Stop(context.Context) error + Enqueue(sound.Cue) bool + EnqueueBatch([]sound.Cue) bool + Available(string) bool +} + +type soundSettingsUpdater interface { + Configure(sound.Configuration) +} + +type soundGeometryCaptureConfigurer interface { + SetCaptureSoundGeometry(bool) +} + +type textAttributeCaptureConfigurer interface { + SetCaptureTextAttributes(bool) +} + +func random_string(messages []string) string { + if len(messages) == 0 { + return "" + } + return messages[rand.IntN(len(messages))] +} + +type commandListener interface { + Commands() <-chan input.CommandEvent + LockStates() <-chan input.LockState + Failures() <-chan error + Start(context.Context) error + Stop(context.Context) error +} + +type foregroundMonitor interface { + Foreground() atspi.ForegroundState + ForegroundChanges() <-chan struct{} +} + +type xtermFocusMonitor interface { + FocusChanges() <-chan xorg.FocusState +} + +type Options struct { + TentacleFile string + TentacleRegistry string + TentacleCgroupParent string + EnableRemote bool + OpenPreferences bool + newCommands func() commandListener + onPreferences func() + newSound func() soundService + Logger *slog.Logger + SpeechSocket string + Settings *config.Settings + newSpeaker func() Speaker + newAdapter func() Adapter + newInput func() InputListener + onNotificationList func() + onSpeechHistory func() + onSpeechMonitor func() +} + +type InputListener interface { + Events() <-chan input.Event + ModifierKeycodes() input.ModifierKeycodes + Overflow() uint64 + Start(context.Context) error + Stop(context.Context) error +} + +const ( + caretDebounce = 150 * time.Millisecond + terminalMutationDebounce = 50 * time.Millisecond + objectReviewCaptureTimeout = 2 * time.Second + flatReviewCaptureTimeout = 10 * time.Second +) + +var startupMessages = []string{ + "Cthulhu welcomes you!", + "Face the thing that should not be!", + "The stars came right again.", +} + +var shutdownMessages = []string{ + "Cthulhu lurks beneath the waves!", + "He watches, lurking beneath the sea.", + "In his house at R’lyeh dead Cthulhu waits dreaming.", +} + +const ( + farewellTimeout = 2 * time.Second + shutdownTimeout = 3 * time.Second + soundStopTimeout = 3 * time.Second +) + +func Run(ctx context.Context, options Options) error { + ctx, stopRun := context.WithCancel(ctx) + defer stopRun() + logger := options.Logger + if logger == nil { + logger = logging.New(nil, logging.Options{}) + } + settings := config.DefaultSettings() + if options.Settings != nil { + settings = *options.Settings + } + if options.newAdapter == nil { + options.newAdapter = func() Adapter { return atspi.New() } + } + + if options.newCommands == nil { + options.newCommands = func() commandListener { + return xorg.NewCommands(settings.CthulhuModifierKeys, input.KeyboardLayout(settings.KeyboardLayout)) + } + } + commands := options.newCommands() + if updater, ok := commands.(interface { + SetKeyBindings(context.Context, []input.BindingOverride) error + }); ok { + if err := updater.SetKeyBindings(ctx, settings.KeyBindings); err != nil { + return err + } + } + focusGate := newFocusGate(commands) + var focusChanges <-chan xorg.FocusState + if monitor, ok := commands.(xtermFocusMonitor); ok { + focusChanges = monitor.FocusChanges() + } + adapter := options.newAdapter() + if configurer, ok := adapter.(soundGeometryCaptureConfigurer); ok { + configurer.SetCaptureSoundGeometry(captureSoundGeometry(settings)) + } + if configurer, ok := adapter.(textAttributeCaptureConfigurer); ok { + configurer.SetCaptureTextAttributes(captureTextAttributes(settings)) + } + backends, err := newRuntimeBackends(&options, settings, logger, adapter) + if err != nil { + return err + } + speechOutput := backends.speechOutput + brailleOutput := backends.brailleOutput + focusGate.braille = brailleOutput + speechMessages := backends.speechMessages + history := backends.history + settingsUpdater := backends.settingsUpdater + inputListener := backends.inputListener + sounds := backends.sounds + services := backends.services + services = append(services, desktopInputService("commands", commands, logger)) + services = append(services, lifecycle.Service{Name: "atspi", Start: adapter.Start, Stop: adapter.Stop}) + var wineReview *wine.ReviewRegistry + if options.EnableRemote { + wineReview = wine.NewReviewRegistry(logger) + } + var wineCapture wineReviewCapturer + if wineReview != nil { + wineCapture = wineReview + } + objectReview, browser, reviewServices, err := newRuntimeReviewServices(logger, commands, adapter, inputListener, focusGate, speechOutput, speechMessages, brailleOutput, wineCapture) + if err != nil { + return err + } + services = append(services, reviewServices...) + live := &liveSettings{clipboardChanges: make(chan struct{}, 1), wineReview: wineReview} + live.Store(settings) + if pointAdapter, ok := adapter.(pointReviewAdapter); ok { + services = append(services, mouseReviewService(logger, pointAdapter, + gatedSpeech{speech: speechOutput, gate: focusGate}, brailleOutput, live, focusGate)) + } + if speechMessages != nil { + speechMessages.reading = func() config.ReadingSettings { return live.Load().Reading } + speechMessages.verbosity = func() int { return live.Load().SpeechVerbosityLevel } + speechMessages.indentationCue = func(cue sound.Cue) { + if sounds != nil { + sounds.EnqueueBatch([]sound.Cue{cue}) + } + } + speechMessages.textTransform = func(text string) string { + settings := live.Load() + return pronunciationSpeechText(readingSpeechText(text, settings.Reading), settings.Pronunciations) + } + } + ocrSession := new_ocr_session(logger, commands, adapter, objectReview, speechOutput, speechMessages, brailleOutput, focusGate, live) + brailleOutput.ocr = ocrSession + brailleOutput.document = objectReview + focusGate.ocr = ocrSession + services = append(services, lifecycle.Service{Name: "OCR", Start: ocrSession.Start, Stop: ocrSession.Stop}) + var tentacles *tentacleRuntime + if options.TentacleFile != "" || options.TentacleRegistry != "" { + tentacles = newTentacleRuntime(logger, commands, options.TentacleFile, options.TentacleCgroupParent) + if options.TentacleFile == "" { + tentacles.registry = &tentacle.Registry{Path: options.TentacleRegistry} + tentacles.requests = make(chan tentacleManagementCall, 16) + tentacles.snapshot.Message = "Loading tentacle configuration." + } + tentacles.output = &tentacleOutput{speech: speechMessages, braille: brailleOutput, gate: focusGate, live: live} + focusGate.tentacleOutput = tentacles.output + services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop}) + } + preferences := newPreferencesWorker(logger, settings, settingsUpdater) + preferences.tentacles = tentacles + preferences.live = live + live.onApplication = preferences.SetApplication + preferences.bindings, _ = commands.(interface { + SetKeyBindings(context.Context, []input.BindingOverride) error + }) + preferences.history = history + preferences.speechWorker = speechMessages + preferences.braille = brailleOutput + preferences.documentReview = objectReview + objectReview.SetDocumentBrailleEnabled(settings.EnableBraille) + if updater, ok := sounds.(soundSettingsUpdater); ok { + preferences.sound = updater + } + if configurer, ok := adapter.(soundGeometryCaptureConfigurer); ok { + preferences.geometry = configurer + } + if configurer, ok := adapter.(textAttributeCaptureConfigurer); ok { + preferences.textAttributes = configurer + } + preferences.socket = options.SpeechSocket + if options.onPreferences == nil { + options.onPreferences = preferences.Open + } + openPreferences := options.onPreferences + options.onPreferences = func() { + ocrSession.Retire() + openPreferences() + } + preferences.announce = func(message string) { + speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "speech-setting"}) + brailleOutput.EnqueueMessage(message) + } + preferences.announceApply = func(message string) { + speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true}) + brailleOutput.FlashConfirmation(message, live.Load().Braille) + } + services = append(services, lifecycle.Service{Name: "preferences", Start: preferences.Start, Stop: preferences.Stop}) + + notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList) + options.onNotificationList = onNotificationList + services = append(services, notificationServices...) + if history != nil && (options.onSpeechHistory == nil || options.onSpeechMonitor == nil) { + windows := newSpeechHistoryWindows(logger, history, outputQueue{ + speech: gatedSpeech{speech: speechOutput, gate: focusGate}, + braille: brailleOutput, live: live, + }, focusGate) + if options.onSpeechHistory == nil { + options.onSpeechHistory = windows.Open + } + preferences.refreshHistory = windows.Refresh + if options.onSpeechMonitor == nil { + options.onSpeechMonitor = windows.ToggleMonitor + } + services = append(services, lifecycle.Service{Name: "speech history", Start: windows.Start, Stop: windows.Stop}) + } + if options.EnableRemote { + services = append(services, newRemoteRuntimeServices(remoteServiceDependencies{ + logger: logger, stopRun: stopRun, adapter: adapter, inputListener: inputListener, + speech: speechMessages, braille: brailleOutput, live: live, + preferences: preferences, onPreferences: options.onPreferences, + })...) + } + runner, err := lifecycle.NewWithLogger(logger, services...) + if err != nil { + return err + } + if err := runner.Start(ctx); err != nil { + return err + } + if settings.EnableBraille { + if err := brailleOutput.Start(); err != nil { + logger.Warn("start braille", "error", err) + } + } + if options.OpenPreferences && options.onPreferences != nil { + options.onPreferences() + } + + presentationOptions := presentationSettingsFromConfig(settings, true) + presentationOptions.SoundCueAvailable = sounds.Available + if soundConfiguration(settings).Enabled { + sounds.Enqueue(sound.Cue{Name: "start"}) + } + if speechMessages != nil && !focusGate.Locked() { + speechMessages.EnqueueStartup(speechRequest{Text: random_string(startupMessages), Role: "system", Cause: "startup"}) + } + commandWorker := commandRuntime{ + logger: logger, commands: commands, focusChanges: focusChanges, focusGate: focusGate, + speechMessages: speechMessages, speechOutput: speechOutput, + browser: browser, preferences: preferences, live: live, + onPreferences: options.onPreferences, onSpeechHistory: options.onSpeechHistory, + onSpeechMonitor: options.onSpeechMonitor, onNotificationList: options.onNotificationList, + tentacles: tentacles, ocr: ocrSession, objectReview: objectReview, notifications: notifications, stopRun: stopRun, + } + commandWorker.actionSource, _ = adapter.(accessibleActionSource) + workers := startRuntimeWorkers(ctx, commandWorker, inputListener, settings, backends.copyChanges, brailleOutput) + var eventInput any = inputListener + if browser != nil { + eventInput = browser + } + runError := processEventsWithAttributes(ctx, logger, outputQueue{speech: gatedSpeech{speech: speechOutput, gate: focusGate}, sounds: sounds, braille: brailleOutput, live: live}, adapter, workers.reviewRequests, workers.attributeRequests, objectReview, caretDebounce, live, eventInput, focusGate, notifications, ocrSession, presentationOptions) + stopError := stopRuntime(runtimeShutdown{ + logger: logger, cancel: stopRun, workers: workers, + tentacles: tentacles, ocr: ocrSession, preferences: preferences, braille: brailleOutput, speech: speechMessages, + focusGate: focusGate, services: runner, + }) + return errors.Join(runError, workers.commandError, stopError) +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..3daef49 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,117 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/sound" +) + +func TestRunSpeaksFocusEventsAndStops(t *testing.T) { + speaker := &fakeSpeaker{spoken: make(chan string, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + inputListener := &fakeInputListener{ + events: make(chan input.Event, 1), + modifierKeycodes: input.NewModifierKeycodes(37), + } + ctx, cancel := context.WithCancel(context.Background()) + + completed := make(chan error, 1) + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), + newSpeaker: func() Speaker { return speaker }, + newCommands: func() commandListener { return silentCommands{} }, + newSound: func() soundService { return silentSound{} }, + newAdapter: func() Adapter { return adapter }, + newInput: func() InputListener { return inputListener }, + }) + }() + + adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", "")) + for index, expected := range []string{"", "Save", "push button"} { + select { + case text := <-speaker.spoken: + if index == 0 { + if !contains_string(startupMessages, text) { + t.Fatalf("startup speech = %q, want one of %q", text, startupMessages) + } + continue + } + if text != expected { + t.Fatalf("spoken text = %q, want %q", text, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %q", expected) + } + } + + cancel() + select { + case err := <-completed: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for Run to stop") + } + select { + case text := <-speaker.spoken: + if !contains_string(shutdownMessages, text) { + t.Fatalf("shutdown speech = %q, want one of %q", text, shutdownMessages) + } + default: + t.Fatal("shutdown message was not spoken") + } + if !adapter.stopped || !speaker.closed || !speaker.canceledOnClose { + t.Fatal("runtime did not stop both services") + } + if !inputListener.started || !inputListener.stopped { + t.Fatal("runtime did not run the X11 listener lifecycle") + } +} + +func (silentSound) Start(context.Context) error { return nil } + +func (silentSound) Stop(context.Context) error { return nil } + +func (silentSound) Enqueue(sound.Cue) bool { return true } + +func (silentSound) EnqueueBatch([]sound.Cue) bool { return true } + +func (silentSound) Available(string) bool { return true } + +func (rejectingSound) Enqueue(sound.Cue) bool { return false } + +func (rejectingSound) EnqueueBatch([]sound.Cue) bool { return false } + +func (silentCommands) Commands() <-chan input.CommandEvent { return nil } + +func (silentCommands) LockStates() <-chan input.LockState { return nil } + +func (silentCommands) Failures() <-chan error { return nil } + +func (silentCommands) Start(context.Context) error { return nil } + +func (silentCommands) Stop(context.Context) error { return nil } diff --git a/internal/app/application_focus_test.go b/internal/app/application_focus_test.go new file mode 100644 index 0000000..57fddaf --- /dev/null +++ b/internal/app/application_focus_test.go @@ -0,0 +1,61 @@ +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestAcceptedApplicationFocusPublishesSettingsIdentity(t *testing.T) { + adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + applications := make(chan string, 4) + live := &liveSettings{onApplication: func(name string) { applications <- name }} + live.Store(config.DefaultSettings()) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + _ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, live, nil, nil, nil) + close(done) + }() + defer func() { cancel(); <-done }() + frame := atspi.NewSnapshot("/first/window", "First", "frame", "", "") + frame.BusName, frame.ApplicationName = ":1.41", "First Editor" + activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) + activated.Window = frame.ID() + adapter.events <- activated + select { + case name := <-applications: + if name != "First Editor" { + t.Fatalf("application = %q", name) + } + case <-time.After(time.Second): + t.Fatal("accepted window did not publish application") + } + background := atspi.NewSnapshot("/background/window", "Background", "frame", "", "") + background.BusName, background.ApplicationName = ":1.42", "Background Editor" + ignored := atspi.NewEvent(atspi.FocusedStateEvent, background) + ignored.Window, ignored.Detail1 = background.ID(), 1 + adapter.events <- ignored + select { + case name := <-applications: + t.Fatalf("background focus published %q", name) + case <-time.After(30 * time.Millisecond): + } + background.Focused = true + focused := atspi.NewEvent(atspi.FocusedStateEvent, background) + focused.Window, focused.Detail1 = background.ID(), 1 + adapter.events <- focused + select { + case name := <-applications: + if name != "Background Editor" { + t.Fatalf("foreground application = %q", name) + } + case <-time.After(time.Second): + t.Fatal("accepted focus did not publish new application") + } +} diff --git a/internal/app/autocomplete.go b/internal/app/autocomplete.go new file mode 100644 index 0000000..8aa50d6 --- /dev/null +++ b/internal/app/autocomplete.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" +) + +// Inline completions are selected by the browser. Echo the inserted portion +// outside that selection; it contains the user's edit, not the suggested suffix. +func autocompleteInsertion(event atspi.Event) string { + text := []rune(event.Payload) + start, end := event.Source.SelectionStart, event.Source.SelectionEnd + if end <= start || end <= event.Detail1 || start >= event.Detail1+len(text) { + return event.Payload + } + left := max(0, start-event.Detail1) + right := min(len(text), end-event.Detail1) + return string(text[:left]) + string(text[right:]) +} + +func recentInput(source any) input.Activity { + if provider, ok := source.(interface{ RecentInput() input.Activity }); ok { + return provider.RecentInput() + } + return input.Activity{} +} + +type autocompleteEcho struct { + sequence uint64 + source atspi.ObjectID +} + +// autocompleteAvailability presents the selected address when it changes. +// Chromium can send several insertion and popup events for the same suggestion. +type autocompleteAvailability struct { + entry atspi.ObjectID + lastAddress string +} + +func supportsAutocomplete(snapshot atspi.Snapshot) bool { + return snapshot.Autocomplete || snapshot.Role == "autocomplete" +} + +func (availability *autocompleteAvailability) focus(event atspi.Event) { + if !isFocusGainEvent(event) || event.Source.AutocompletePopup { + return + } + entry := atspi.ObjectID{} + if supportsAutocomplete(event.Source) { + entry = event.Source.ID() + } + if entry != availability.entry { + availability.entry = entry + availability.lastAddress = "" + } +} + +func (availability *autocompleteAvailability) offered(event atspi.Event, activity input.Activity) string { + if !availability.entry.Valid() || event.Type != atspi.TextInsertedEvent || + !supportsAutocomplete(event.Source) || event.Source.ID() != availability.entry || + (activity.Intent != input.TypingIntent && activity.Intent != input.DeleteIntent) { + return "" + } + address := strings.TrimSpace(event.Source.Text) + if event.Source.SelectionEnd <= event.Source.SelectionStart || + event.Source.SelectionEnd > len([]rune(address)) || address == "" || + address == availability.lastAddress { + return "" + } + availability.lastAddress = address + return address +} + +func (echo *autocompleteEcho) insertion(event atspi.Event, activity input.Activity) string { + text := autocompleteInsertion(event) + if text == "" && activity.Sequence != 0 && + (activity.Sequence != echo.sequence || event.Source.ID() != echo.source) && + event.Detail1 == event.Source.SelectionStart { + // Chromium can retain an unchanged typed prefix and emit only a replacement + // of the selected suffix. Recover that character from accessible text once + // for this key press; a normal insertion already covers the other case. + text = event.Source.CompletionPrefixCharacter + } + if text != "" { + echo.sequence, echo.source = activity.Sequence, event.Source.ID() + } + return text +} diff --git a/internal/app/braille_context.go b/internal/app/braille_context.go new file mode 100644 index 0000000..2c01cdf --- /dev/null +++ b/internal/app/braille_context.go @@ -0,0 +1,76 @@ +package app + +import ( + "strings" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" +) + +// Python includes context at the first line of text areas and omits desktop +// ancestry within web documents. Verbosity changes roles, not group labels. +func braille_context_frame(frame braille.Frame, snapshot atspi.Snapshot, options config.BrailleOptions) braille.Frame { + if snapshot.Role == "terminal" || snapshot.TextCaretKnown && snapshot.BrailleLineStart > 0 { + return frame + } + for _, ancestor := range snapshot.BrailleContext { + if atspi.IsWebDocumentRole(ancestor.Role) { + return frame + } + } + parts := []string{} + groupSkipped := false + ancestors := snapshot.BrailleContext + if len(ancestors) > 0 { + switch ancestors[0].Role { + case "menu", "menu bar", "page tab list", "redundant object", "unknown", "combo box": + ancestors = ancestors[1:] + } + } + for index := len(ancestors) - 1; index >= 0; index-- { + ancestor := ancestors[index] + if ancestor.ID().Valid() && ancestor.ID() == snapshot.ID() { + continue + } + switch ancestor.Role { + case "invalid", "unknown", "redundant object", "section", "split pane", "desktop frame", "scroll pane", "viewport": + continue + case "panel", "filler", "grouping": + if strings.TrimSpace(ancestor.Name) == "" { + continue + } + if snapshot.Role == "radio button" && ancestor.Name == snapshot.RadioGroupName && !groupSkipped { + groupSkipped = true + continue + } + } + if ancestor.Role == "table" && ancestor.LayoutTable { + continue + } + text := braille_object_frame(ancestor, options).Text + if text != "" { + parts = append(parts, text) + } + } + parts = append(parts, snapshot.TableRowHeaders, snapshot.TableColumnHeaders, snapshot.RadioGroupName) + prefix := brailleJoin(parts...) + if prefix == "" { + return frame + } + prefix += " " + shift := utf8.RuneCountInString(prefix) + frame.Text = prefix + frame.Text + if frame.Cursor >= 0 { + frame.Cursor += shift + } + if len(frame.Dots) != 0 { + frame.Dots = append(make([]byte, shift), frame.Dots...) + } + for index := range frame.Routes { + frame.Routes[index].Start += shift + frame.Routes[index].End += shift + } + return frame +} diff --git a/internal/app/braille_context_test.go b/internal/app/braille_context_test.go new file mode 100644 index 0000000..fab0c8c --- /dev/null +++ b/internal/app/braille_context_test.go @@ -0,0 +1,99 @@ +package app + +import ( + "reflect" + "testing" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestBrailleContextVerbosityAndRoutingOffsets(t *testing.T) { + options := config.DefaultSettings().Braille + source := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Role: "entry", Name: "Title", TextCaretKnown: true, + BrailleLine: "é猫", BrailleCaret: 1, SelectionStart: 0, SelectionEnd: 1, + BrailleContext: []atspi.Snapshot{{Role: "panel", Name: "Details"}, {Role: "dialog", Name: "Properties"}, {Role: "application", Name: "Editor"}}} + for _, test := range []struct { + verbosity int + prefix string + }{ + {1, "Editor application Properties dialog Details panel "}, + {0, "Editor Properties Details "}, + } { + options.Verbosity = test.verbosity + frame := brailleFocusFrameWithOptions(source, options) + shift := utf8.RuneCountInString(test.prefix) + 6 + if frame.Text != test.prefix+"Title é猫 $l" || frame.Cursor != shift+1 || len(frame.Routes) != 1 || frame.Routes[0].Start != shift || frame.Dots[shift] != byte(options.SelectionIndicator) { + t.Fatalf("verbosity %d = %+v", test.verbosity, frame) + } + } + options.Verbosity, options.AbbreviateRoles = 1, true + if frame := brailleFocusFrameWithOptions(source, options); frame.Text != "Editor app Properties dlg Details pnl Title é猫 $l" { + t.Fatalf("abbreviated context = %q", frame.Text) + } + source.BrailleLineStart, source.BrailleCaret = 4, 5 + if frame := brailleFocusFrameWithOptions(source, options); frame.Text != "Title é猫 $l" { + t.Fatalf("later line repeats context: %+v", frame) + } + source.BrailleContext = append(source.BrailleContext, atspi.Snapshot{Role: "document web"}) + source.BrailleLineStart, source.BrailleCaret = 0, 1 + if frame := brailleFocusFrameWithOptions(source, options); frame.Text != "Title é猫 $l" { + t.Fatalf("web text includes desktop ancestry: %+v", frame) + } + options.SelectionIndicator, options.LinkIndicator = 0x40, 0x80 + source.Role, source.Name = "static", "é猫" + source.BrailleContext = []atspi.Snapshot{{Role: "link"}, {Role: "document web"}} + if frame := brailleFocusFrameWithOptions(source, options); frame.Text != "é猫 $l" || frame.Dots[0] != 0xc0 || frame.Dots[1] != 0x80 || frame.Dots[2] != 0 { + t.Fatalf("native browse link selection = %+v", frame) + } +} + +func TestBrailleContextHeadersGroupsAndBriefRoles(t *testing.T) { + options := config.DefaultSettings().Braille + options.Verbosity = 0 + radio := atspi.Snapshot{Role: "radio button", Name: "Brief", RadioGroupName: "Verbosity", BrailleContext: []atspi.Snapshot{{Role: "panel", Name: "Verbosity"}}} + if frame := brailleFocusFrameWithOptions(radio, options); frame.Text != "Verbosity & y Brief" { + t.Fatalf("radio group = %q", frame.Text) + } + cell := atspi.Snapshot{Role: "table cell", Name: "10", TableRowHeaders: "January", TableColumnHeaders: "Total"} + if frame := brailleFocusFrameWithOptions(cell, options); frame.Text != "January Total 10" { + t.Fatalf("table headers = %q", frame.Text) + } + if frame := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "button"}, options); frame.Text != "" { + t.Fatalf("brief unnamed role = %q", frame.Text) + } + if frame := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "heading", Name: "Title", HeadingLevel: 2}, options); frame.Text != "Title h2" { + t.Fatalf("brief heading = %q", frame.Text) + } +} + +func TestBrowseBrailleMapsInlineSelectionsAndLinkDots(t *testing.T) { + options := config.DefaultSettings().Braille + options.SelectionIndicator, options.LinkIndicator = 0x40, 0x80 + first := atspi.Snapshot{BusName: ":1.42", Path: "/first", Role: "static", SelectionStart: 1, SelectionEnd: 2} + second := atspi.Snapshot{BusName: ":1.42", Path: "/second", Role: "static", SelectionStart: 7, SelectionEnd: 10, BrailleContext: []atspi.Snapshot{{Role: "link"}}} + doc := atspi.Snapshot{BusName: first.BusName, Path: "/document", Role: "document web"} + tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{first.ID(), second.ID()}}, {Snapshot: first}, {Snapshot: second}}) + if err != nil { + t.Fatal(err) + } + docRef, _ := tree.Reference(doc.ID()) + result := review.Result{DocumentEpoch: 3, DocumentFocus: doc.ID(), DocumentPosition: review.DocumentPosition{Document: docRef, Snapshot: second, Offset: 8}, + DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: first, Start: 0, End: 2, RawText: "é猫"}, {Snapshot: second, Start: 7, End: 11, RawText: "word", Separator: " "}}}} + frame := brailleDocumentFrame(result, options) + if frame.Text != "é猫 word" || frame.Cursor != 4 || frame.Document == nil || frame.Document.Epoch != 3 || len(frame.Routes) != 2 || frame.Routes[1].SourceStart != 7 || frame.Routes[1].Start != 3 { + t.Fatalf("inline frame = %+v", frame) + } + if !reflect.DeepEqual(frame.Dots, []byte{0, 0x40, 0, 0xc0, 0xc0, 0xc0, 0x80}) { + t.Fatalf("inline dots = %v", frame.Dots) + } + result.DocumentCaretLine = nil + result.DocumentBrailleLine = &atspi.DocumentCaretText{Start: 7, Text: "word\n", SelectionStart: 8, SelectionEnd: 10} + result.DocumentPosition.Offset = 11 + frame = brailleDocumentFrame(result, options) + if frame.Text != "word " || frame.Cursor != 4 || frame.Routes[0].End != 5 || frame.Routes[0].TextLength != 4 { + t.Fatalf("end cursor route = %+v", frame) + } +} diff --git a/internal/app/braille_frames.go b/internal/app/braille_frames.go new file mode 100644 index 0000000..019f6dc --- /dev/null +++ b/internal/app/braille_frames.go @@ -0,0 +1,299 @@ +package app + +import ( + "context" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func (worker *brailleWorker) Enqueue(text string) { + worker.EnqueueFrame(braille.Frame{Text: text, Cursor: -1}) +} + +func (worker *brailleWorker) EnqueueMessage(text string) bool { + if text == "" || len(text) > 10000 { + return false + } + worker.mu.Lock() + if !worker.connected { + worker.mu.Unlock() + return false + } + queued := worker.enqueueFrameLocked(braille.Frame{Text: text, Cursor: -1, WordWrap: worker.wordWrap}, true) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } + return queued +} + +func (worker *brailleWorker) EnqueueFrame(frame braille.Frame) { + worker.enqueueFrame(frame, true) +} + +// SetInternalUIFrame temporarily owns presentation while normal focus frames +// continue updating the base. Closing restores the newest application frame. +func (worker *brailleWorker) SetInternalUIFrame(frame *braille.Frame) { + worker.mu.Lock() + if worker.flashTimer != nil { + worker.flashTimer.Stop() + worker.flashTimer = nil + } + worker.flashGeneration++ + worker.flashContext = nil + worker.protectedFlash = false + worker.internalUIFrame = nil + next := worker.baseFrame + if frame != nil { + next = *frame + next.WordWrap = worker.wordWrap + if len(next.Text) > 10000 { + next.Text = next.Text[:10000] + for !utf8.ValidString(next.Text) { + next.Text = next.Text[:len(next.Text)-1] + } + if next.Cursor >= utf8.RuneCountInString(next.Text) { + next.Cursor = -1 + } + } + } + queued := worker.enqueueFrameLocked(next, false) + if frame != nil { + copy := *frame + worker.internalUIFrame = © + } + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } +} + +func (worker *brailleWorker) enqueueFrame(frame braille.Frame, base bool) { + // JSON escaping can expand a frame; keep it below the helper's 64 KiB + // scanner limit even when every byte needs escaping. + if len(frame.Text) > 10000 { + frame.Text = frame.Text[:10000] + for !utf8.ValidString(frame.Text) { + frame.Text = frame.Text[:len(frame.Text)-1] + } + if frame.Cursor > utf8.RuneCountInString(frame.Text) { + frame.Cursor = -1 + } + if len(frame.Dots) > utf8.RuneCountInString(frame.Text) { + frame.Dots = frame.Dots[:utf8.RuneCountInString(frame.Text)] + } + } + worker.mu.Lock() + queued := worker.enqueueFrameLocked(frame, base) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } +} + +// enqueueFrameLocked must run under mu so flash, focus and timer restoration +// cannot reorder their frames. +func (worker *brailleWorker) enqueueFrameLocked(frame braille.Frame, base bool) bool { + if worker.secure { + return false + } + return worker.enqueueSecureFrameLocked(frame, base) +} + +func (worker *brailleWorker) enqueueSecureFrameLocked(frame braille.Frame, base bool) bool { + if worker.internalUIFrame != nil { + if base { + worker.baseFrame = frame + } + return false + } + if worker.cancel == nil || worker.stopping { + return false + } + if base { + worker.flashContext = nil + if worker.protectedFlash { + worker.baseFrame = frame + return false + } + worker.flashGeneration++ + if worker.flashTimer != nil { + worker.flashTimer.Stop() + worker.flashTimer = nil + } + worker.baseFrame = frame + } + frame.Contract = worker.contracted + frame.Table = worker.table + frame.ExpandAtCursor = frame.Cursor >= 0 && len(frame.Routes) > 0 + worker.sequence++ + frame.ID = worker.sequence + frame.Routes = append([]braille.TextRoute(nil), frame.Routes...) + frame.Dots = append([]byte(nil), frame.Dots...) + if worker.routeCancel != nil { + worker.routeCancel() + } + worker.frame = frame + return true +} + +func (worker *brailleWorker) wakeDisplay() { + select { + case worker.wake <- struct{}{}: + default: + } +} + +func (worker *brailleWorker) Flash(text string, options config.BrailleOptions) bool { + return worker.flash_message(text, options, false) +} + +// Confirmation flashes survive the automatic focus change caused by Apply/OK. +// Normal flashes keep their existing focus-replacement behavior. +func (worker *brailleWorker) FlashConfirmation(text string, options config.BrailleOptions) bool { + return worker.flash_message(text, options, true) +} + +func (worker *brailleWorker) flash_message(text string, options config.BrailleOptions, confirmation bool) bool { + return worker.flash_message_guarded(text, options, confirmation, nil, 0) +} + +func (worker *brailleWorker) flash_message_guarded(text string, options config.BrailleOptions, confirmation bool, owner context.Context, sequence uint64) bool { + if text == "" || !options.FlashMessages { + return false + } + if len(text) > 10000 { + text = text[:10000] + for !utf8.ValidString(text) { + text = text[:len(text)-1] + } + } + worker.mu.Lock() + if worker.internalUIFrame != nil { + worker.mu.Unlock() + return false + } + if owner != nil && (owner.Err() != nil || worker.sequence != sequence) { + worker.mu.Unlock() + return false + } + if worker.cancel == nil || worker.stopping || !worker.connected || worker.secure { + worker.mu.Unlock() + return false + } + worker.flashGeneration++ + worker.flashContext = owner + worker.protectedFlash = confirmation + generation := worker.flashGeneration + if worker.flashTimer != nil { + worker.flashTimer.Stop() + } + queued := worker.enqueueFrameLocked(braille.Frame{Text: text, Cursor: -1, WordWrap: options.WordWrap}, false) + if !options.FlashPersistent && options.FlashTime > 0 { + worker.flashTimer = time.AfterFunc(time.Duration(options.FlashTime)*time.Millisecond, func() { + worker.mu.Lock() + if worker.flashGeneration != generation { + worker.mu.Unlock() + return + } + worker.flashTimer = nil + worker.flashContext = nil + worker.protectedFlash = false + queued := worker.enqueueFrameLocked(worker.baseFrame, false) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } + }) + } + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } + return true +} + +func (worker *brailleWorker) DismissConfirmation() { + worker.mu.Lock() + if !worker.protectedFlash { + worker.mu.Unlock() + return + } + worker.protectedFlash = false + worker.flashGeneration++ + if worker.flashTimer != nil { + worker.flashTimer.Stop() + worker.flashTimer = nil + } + queued := worker.enqueueFrameLocked(worker.baseFrame, false) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } +} + +func (worker *brailleWorker) SetContraction(enabled bool, table string) { + worker.mu.Lock() + worker.contracted, worker.table = enabled, table + frame := worker.frame + running := worker.cancel != nil && !worker.stopping + queued := false + if running { + queued = worker.enqueueFrameLocked(frame, false) + } + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } +} + +func (worker *brailleWorker) SetWordWrap(enabled bool) { + worker.mu.Lock() + if worker.wordWrap == enabled { + worker.mu.Unlock() + return + } + worker.wordWrap = enabled + worker.baseFrame.WordWrap = enabled + frame := worker.frame + frame.WordWrap = enabled + queued := worker.enqueueFrameLocked(frame, false) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } +} + +func (worker *brailleWorker) current() braille.Frame { + worker.mu.Lock() + defer worker.mu.Unlock() + if worker.flashContext != nil && worker.flashContext.Err() != nil { + worker.retirePresentationLocked(worker.flashContext) + } + return worker.frame +} + +func (worker *brailleWorker) retirePresentationLocked(owner context.Context) bool { + if worker.flashContext != owner { + return false + } + worker.flashContext = nil + worker.flashGeneration++ + if worker.flashTimer != nil { + worker.flashTimer.Stop() + worker.flashTimer = nil + } + return worker.enqueueFrameLocked(worker.baseFrame, false) +} + +func (worker *brailleWorker) retirePresentation(owner context.Context) { + worker.mu.Lock() + queued := worker.retirePresentationLocked(owner) + worker.mu.Unlock() + if queued { + worker.wakeDisplay() + } +} diff --git a/internal/app/braille_helper_test.go b/internal/app/braille_helper_test.go new file mode 100644 index 0000000..2b0fbc7 --- /dev/null +++ b/internal/app/braille_helper_test.go @@ -0,0 +1,104 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/logging" +) + +// The test executable doubles as a real pipe-protocol peer. Only the explicit +// child invocation takes this path; the parent runs the ordinary test suite. +func TestMain(m *testing.M) { + if os.Getenv("CTHULHU_BRAILLE_TEST_FAIL") == "1" && len(os.Args) == 2 && os.Args[1] == "--client" { + fmt.Fprintln(os.Stderr, "braille test startup failure") + os.Exit(1) + } + if os.Getenv("CTHULHU_BRAILLE_TEST_CLIENT") == "1" && len(os.Args) == 2 && os.Args[1] == "--client" { + fmt.Println("ready") + decoder, encoder := json.NewDecoder(os.Stdin), json.NewEncoder(os.Stdout) + for { + var frame braille.Frame + if err := decoder.Decode(&frame); err != nil { + os.Exit(0) + } + if frame.Text != "" { + if err := encoder.Encode(braille.RoutingEvent{ID: frame.ID, Offset: 1}); err != nil { + os.Exit(1) + } + } + } + } + os.Exit(m.Run()) +} + +func TestBrailleWorkerReportsHelperStartupFailure(t *testing.T) { + t.Setenv("CTHULHU_BRAILLE_TEST_FAIL", "1") + path, err := os.Executable() + if err != nil { + t.Fatal(err) + } + worker := &brailleWorker{path: path, logger: slog.New(slog.DiscardHandler)} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := worker.session(ctx); err == nil || !strings.Contains(err.Error(), "braille test startup failure") { + t.Fatalf("helper startup error = %v", err) + } +} + +func TestBrailleWorkerHelperProtocolAndRestart(t *testing.T) { + t.Setenv("CTHULHU_BRAILLE_TEST_CLIENT", "1") + path, err := os.Executable() + if err != nil { + t.Fatal(err) + } + router := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 8)} + var output bytes.Buffer + worker := &brailleWorker{path: path, router: router, logger: logging.New(&output, logging.Options{Level: slog.LevelDebug}), wake: make(chan struct{}, 1)} + stop := func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := worker.Stop(ctx); err != nil { + t.Errorf("stop helper: %v", err) + } + } + defer stop() + for attempt := 0; attempt < 2; attempt++ { + if err := worker.Start(); err != nil { + t.Fatal(err) + } + worker.EnqueueFrame(braille.Frame{Text: "é猫", Cursor: 0, Routes: []braille.TextRoute{{Start: 0, End: 2, TextLength: 2, SourceStart: 10, BusName: ":1.42", Path: "/entry", SourceText: "é猫"}}}) + select { + case route := <-router.calls: + if route.Offset != 11 || route.Focus.Path != "/entry" { + t.Fatalf("route %+v", route) + } + case <-time.After(3 * time.Second): + t.Fatal("helper route was not delivered") + } + stop() + // Stop joins the writer before inspecting its debug output. + for _, expected := range []string{`"message":"sent braille frame"`, `"braille_text":"é猫"`, `"cursor_offset":0`} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("braille diagnostics missing %s: %s", expected, output.String()) + } + } + // A duplicate send can be coalesced by either side; discard already handled + // keys before checking that the restarted child returns a new event. + for len(router.calls) > 0 { + <-router.calls + } + if worker.current().Text != "" { + t.Fatal("stopped worker retained old content") + } + } +} diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go new file mode 100644 index 0000000..bae52dc --- /dev/null +++ b/internal/app/braille_presentation.go @@ -0,0 +1,431 @@ +package app + +import ( + "fmt" + "math" + "strings" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/review" +) + +type brailleFrameEnqueuer interface{ EnqueueFrame(braille.Frame) } + +// Browse cells map to native object offsets under the page's review epoch. +func brailleDocumentFrame(result review.Result, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { + snapshot := result.DocumentPosition.Snapshot + line, assembled := result.DocumentBrailleLine, result.DocumentCaretLine + if line == nil && assembled == nil || snapshot.Role == "password text" { + frame := brailleFocusFrameWithOptions(snapshot, options, configured...) + frame.Routes = nil + textLength := utf8.RuneCountInString(snapshot.Name) + if textLength == 0 { + textLength = utf8.RuneCountInString(snapshot.Text) + } + return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength) + } + frame := braille.Frame{Cursor: -1, WordWrap: options.WordWrap} + appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int) { + frame.Text += separator + start := utf8.RuneCountInString(frame.Text) + frame.Text += raw + length := utf8.RuneCountInString(raw) + if source.ID() == snapshot.ID() { + frame.Cursor = start + result.DocumentPosition.Offset - sourceStart + } + if source.ID().Valid() && source.Role != "password text" && length > 0 { + frame.Routes = append(frame.Routes, braille.TextRoute{Start: start, End: start + length, + SourceStart: sourceStart, TextLength: length, BusName: source.BusName, Path: source.Path, SourceText: raw}) + } + if options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart { + frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) + for offset := max(source.SelectionStart, sourceStart); offset < min(source.SelectionEnd, sourceStart+length); offset++ { + frame.Dots[start+offset-sourceStart] |= byte(options.SelectionIndicator) + } + } + isLink := source.Role == "link" + for _, ancestor := range source.BrailleContext { + isLink = isLink || ancestor.Role == "link" + } + if options.LinkIndicator != 0 && isLink { + frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) + for offset := start; offset < start+length; offset++ { + frame.Dots[offset] |= byte(options.LinkIndicator) + } + } + if len(configured) != 0 { + if len(source.TextAttributeRuns) != 0 { + for _, run := range source.TextAttributeRuns { + markTextAttributeRun(&frame, run, sourceStart, start, length, configured[0]) + } + } else { + markTextAttributeRun(&frame, source.TextAttributeRun, sourceStart, start, length, configured[0]) + } + } + } + if assembled != nil { + for index, content := range assembled.Contents { + raw := content.RawText + if index == len(assembled.Contents)-1 { + raw = strings.TrimRight(raw, "\r\n") + } + if content.Snapshot.Role == "password text" { + continue + } + appendContent(content.Snapshot, raw, content.Separator, content.Start) + } + } else { + snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd + snapshot.TextAttributeRuns = line.TextAttributeRuns + appendContent(snapshot, strings.TrimRight(line.Text, "\r\n"), "", line.Start) + } + textLength := utf8.RuneCountInString(frame.Text) + if frame.Cursor < 0 || frame.Cursor > textLength { + frame.Cursor = -1 + } else if frame.Cursor == textLength { + frame.Text += " " + for index := range frame.Routes { + if frame.Routes[index].End == textLength { + frame.Routes[index].End++ + } + } + } + if result.DocumentFocus.Valid() && result.DocumentPosition.Document.Valid() { + doc := result.DocumentPosition.Document.ID() + frame.Document = &braille.DocumentRoute{Epoch: result.DocumentEpoch, FocusBus: result.DocumentFocus.BusName, + FocusPath: result.DocumentFocus.Path, DocumentBus: doc.BusName, DocumentPath: doc.Path} + } else { + frame.Routes = nil + } + ancestors := append([]atspi.Snapshot{snapshot}, result.DocumentCaretContext...) + for _, ancestor := range ancestors { + if ancestor.Role == "heading" { + frame.Text = brailleJoin(frame.Text, fmt.Sprintf("h%d", ancestor.HeadingLevel)) + break + } + } + if options.LinkIndicator != 0 && assembled == nil { + for _, ancestor := range ancestors { + if ancestor.Role == "link" { + frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) + for index := 0; index < textLength; index++ { + frame.Dots[index] |= byte(options.LinkIndicator) + } + break + } + } + } + if len(frame.Dots) != 0 { + frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) + } + return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength) +} + +func linkedHeadingBraille(frame braille.Frame, link *atspi.Snapshot, options config.BrailleOptions, textLength int) braille.Frame { + if link == nil { + return frame + } + contentLength := utf8.RuneCountInString(frame.Text) + textLength = min(contentLength, max(0, textLength)) + if options.LinkIndicator != 0 { + if len(frame.Dots) < contentLength { + frame.Dots = append(frame.Dots, make([]byte, contentLength-len(frame.Dots))...) + } + for index := 0; index < textLength; index++ { + frame.Dots[index] |= byte(options.LinkIndicator) + } + } + role := "lnk" + if link.Visited { + role = "vlnk" + } + frame.Text = brailleJoin(frame.Text, role) + if len(frame.Dots) != 0 { + frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-contentLength)...) + } + return frame +} + +// Formats the captured object and bounded context using Python formatting.py's +// braille rules and object_properties.py's indicators. +func brailleFocusFrame(s atspi.Snapshot) braille.Frame { + return brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille) +} + +func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { + return braille_context_frame(braille_object_frame(s, options, configured...), s, options) +} + +func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { + name, role := strings.TrimSpace(s.Name), s.Role + if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown { + label := name + if role == "terminal" || role == "heading" || role == "static" { + label = "" + } + line := strings.TrimRight(s.BrailleLine, "\r\n") + if label == line { + label = "" + } + prefix := "" + if label != "" { + prefix = label + " " + } + text := prefix + line + end := utf8.RuneCountInString(text) + cursor := -1 + relative := s.BrailleCaret - s.BrailleLineStart + if s.TextCaretKnown && relative >= 0 && relative <= utf8.RuneCountInString(s.BrailleLine) { + cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line)) + } + if role != "terminal" && role != "heading" && !options.DisableEOL { + text += " $l" + } else if cursor == end { + text += " " + } + frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap} + if options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart { + frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text)) + start := utf8.RuneCountInString(prefix) + for source := max(s.SelectionStart, s.BrailleLineStart); source < min(s.SelectionEnd, s.BrailleLineStart+utf8.RuneCountInString(line)); source++ { + frame.Dots[start+source-s.BrailleLineStart] = byte(options.SelectionIndicator) + } + } + if options.LinkIndicator != 0 { + for _, ancestor := range s.BrailleContext { + if ancestor.Role != "link" { + continue + } + if len(frame.Dots) == 0 { + frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text)) + } + start := utf8.RuneCountInString(prefix) + for index := start; index < start+utf8.RuneCountInString(line); index++ { + frame.Dots[index] |= byte(options.LinkIndicator) + } + break + } + } + if len(configured) != 0 { + if len(s.TextAttributeRuns) != 0 { + for _, run := range s.TextAttributeRuns { + markTextAttributeRun(&frame, run, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0]) + } + } else { + markTextAttributeRun(&frame, s.TextAttributeRun, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0]) + } + } + // Only actual text and its EOL marker route; labels and state suffixes do not. + if s.TextCaretKnown && s.ID().Valid() { + frame.Routes = []braille.TextRoute{{Start: utf8.RuneCountInString(prefix), End: utf8.RuneCountInString(text), + SourceStart: s.BrailleLineStart, TextLength: utf8.RuneCountInString(line), + BusName: s.BusName, Path: s.Path, SourceText: s.BrailleLine}} + } + if role == "heading" { + frame.Text = brailleJoin(frame.Text, fmt.Sprintf("h%d", s.HeadingLevel)) + } else if role != "terminal" { + if s.Invalid && role != "spin button" && role != "document frame" && role != "document web" { + frame.Text += " invalid" + } + if s.Required { + frame.Text += " required" + } + if s.ReadOnly { + frame.Text += " rdonly" + } + } + if len(frame.Dots) != 0 && len(frame.Dots) < utf8.RuneCountInString(frame.Text) { + frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) + } + return frame + } + // Protected text is never copied, even if a provider supplies it accidentally. + if role == "password text" { + return braille.Frame{Text: brailleJoin(name, role), Cursor: 0} + } + roleName := role + switch role { + case "invalid", "unknown", "redundant object", "filler", "extended", "link": + roleName = "" + case "heading": + roleName = fmt.Sprintf("h%d", s.HeadingLevel) + } + if options.Verbosity == 0 && role != "heading" { + roleName = "" + } + if options.AbbreviateRoles && roleName != "" && role != "heading" { + roleName = shortBrailleRole(roleName) + } + expanded := "" + if s.Expandable { + expanded = "collapsed" + if s.Expanded { + expanded = "expanded" + } + } + available := "" + if s.SensitiveKnown && !s.Sensitive { + available = "grayed" + } + indicator := "" + if s.Checked { + indicator = "" + } else if s.Indeterminate { + indicator = "<->" + } else { + indicator = "< >" + } + value := brailleValue(s) + text := "" + cursor := 0 + switch role { + case "check box": + text = brailleJoin(indicator, name, roleName) + case "check menu item": + text = brailleJoin(indicator, name, roleName, available) + case "radio button", "radio menu item", "toggle button": + indicator = "& y" + if s.Checked { + indicator = "&=y" + } + if name == "" { + name = s.Description + } + if role == "toggle button" { + text = brailleJoin(indicator, name, expanded, roleName) + } else { + text = brailleJoin(indicator, name, roleName) + if role == "radio menu item" { + text = brailleJoin(text, available) + } + } + case "push button", "button": + if name == "" { + name = s.Description + } + text = brailleJoin(name, expanded, roleName) + case "menu item": + text = brailleJoin(name, expanded, available) + case "tree item": + text = brailleJoin(name, expanded) + case "list item": + text = brailleJoin(name, expanded, roleName, available) + case "menu", "page tab": + text = brailleJoin(name, roleName, available) + case "combo box": + if value == "" { + value = s.Description + } + text = brailleJoin(name, value, roleName) + if name != "" && value != "" { + cursor = utf8.RuneCountInString(name) + 1 + } + case "dial", "scroll bar", "slider", "spin button": + text = brailleJoin(name, value, roleName) + if s.Required { + text = brailleJoin(text, "required") + } + if role == "slider" && s.Invalid { + text = brailleJoin(text, "invalid") + } + case "progress bar": + text = brailleJoin(name, value, roleName) + default: + displayed := s.Text + if displayed == name { + displayed = "" + } + text = brailleJoin(name, displayed, value, roleName) + if s.Required { + text = brailleJoin(text, "required") + } + if s.Invalid { + text = brailleJoin(text, "invalid") + } + } + if text == "" { + cursor = -1 + } + frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap} + if role == "link" && options.LinkIndicator != 0 { + frame.Dots = make([]byte, utf8.RuneCountInString(text)) + for index := range frame.Dots { + frame.Dots[index] |= byte(options.LinkIndicator) + } + } + return frame +} + +func shortBrailleRole(role string) string { + if short, ok := map[string]string{ + "check box": "chk", "check menu item": "chk", "radio button": "radio", + "push button": "btn", "button": "btn", "combo box": "cbo", + "list item": "lstitm", "page tab": "pgt", "text": "txt", + "link": "lnk", "slider": "sldr", "spin button": "spin", + "menu": "mnu", "menu item": "mnuitm", "progress bar": "pgbar", + "toggle button": "tglbtn", "password text": "pwd", "label": "lbl", + "application": "app", "frame": "frm", "dialog": "dlg", "panel": "pnl", + "list": "lst", "table": "tbl", "table cell": "cll", + "column header": "colhdr", "row header": "rwhdr", + }[role]; ok { + return short + } + return role +} + +func brailleTextRole(role string) bool { + switch role { + case "entry", "text", "static", "paragraph", "section", "label", "page", "document frame", "document web", "heading", "spin button": + return true + } + return false +} + +func brailleJoin(parts ...string) string { + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + out = append(out, part) + } + } + return strings.Join(out, " ") +} + +func brailleValue(s atspi.Snapshot) string { + if s.Role == "progress bar" { + if !s.ValueKnown || s.Indeterminate && s.CurrentValue <= 0 { + return "" + } + if s.MaximumValue == s.MinimumValue { + if s.CurrentValue == s.MaximumValue && s.CurrentValue >= 1 && s.CurrentValue <= 100 { + return fmt.Sprintf("%.0f%%", math.Trunc(s.CurrentValue)) + } + return "" + } + value := (s.CurrentValue - s.MinimumValue) / (s.MaximumValue - s.MinimumValue) * 100 + if math.IsNaN(value) || math.IsInf(value, 0) { + return "" + } + return fmt.Sprintf("%.0f%%", math.Trunc(value)) + } + if s.ValueText != "" { + return s.ValueText + } + if !s.ValueKnown || math.IsNaN(s.CurrentValue) || math.IsInf(s.CurrentValue, 0) { + return "" + } + increment := s.MinimumIncrement + if increment < 0 || math.IsNaN(increment) || math.IsInf(increment, 0) { + return "" + } + places := 0 + if increment > 0 { + places = int(math.Ceil(math.Max(0, -math.Log10(increment)))) + } else if math.Abs(s.CurrentValue) < 1 { + places = 1 + } + return fmt.Sprintf("%.*f", places, s.CurrentValue) +} diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go new file mode 100644 index 0000000..127e2f3 --- /dev/null +++ b/internal/app/braille_presentation_test.go @@ -0,0 +1,397 @@ +package app + +import ( + "context" + "log/slog" + "reflect" + "strings" + "testing" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/review" +) + +type frameRecorder struct{ frames chan braille.Frame } + +type flashRecorder struct{ messages []string } + +func (*flashRecorder) EnqueueFrame(braille.Frame) {} +func (recorder *flashRecorder) Flash(message string, _ config.BrailleOptions) bool { + recorder.messages = append(recorder.messages, message) + return true +} + +func TestSystemMessageFlashesOnBraille(t *testing.T) { + live := &liveSettings{} + live.Store(config.DefaultSettings()) + braille := &flashRecorder{} + queue := outputQueue{speech: &remoteSpeechQueue{}, braille: braille, live: live} + queue.Enqueue(speechRequest{Text: "Copied", Role: "system", Cause: "clipboard"}) + queue.Enqueue(speechRequest{Text: "Current line", Role: "system", Cause: "document-caret-line"}) + if len(braille.messages) != 1 || braille.messages[0] != "Copied" { + t.Fatalf("flash messages = %v", braille.messages) + } +} + +func TestBrailleOptionsChangePresentation(t *testing.T) { + options := config.DefaultSettings().Braille + options.DisableEOL = true + options.WordWrap = true + options.SelectionIndicator = 0x40 + entry := atspi.Snapshot{Role: "entry", Name: "Title", BrailleLine: "abcd", BrailleLineStart: 10, + BrailleCaret: 12, TextCaretKnown: true, SelectionStart: 11, SelectionEnd: 13} + frame := brailleFocusFrameWithOptions(entry, options) + if frame.Text != "Title abcd" || !frame.WordWrap || len(frame.Dots) != 10 || frame.Dots[7] != 0x40 || frame.Dots[8] != 0x40 { + t.Fatalf("entry frame = %+v", frame) + } + options.AbbreviateRoles = true + if got := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "button", Name: "Save"}, options).Text; got != "Save btn" { + t.Fatalf("abbreviated button = %q", got) + } + options.Verbosity = 0 + if got := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "button", Name: "Save"}, options).Text; got != "Save" { + t.Fatalf("brief button = %q", got) + } + options.LinkIndicator = 0x80 + link := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "link", Name: "Help"}, options) + if len(link.Dots) != 4 || link.Dots[0] != 0x80 || link.Dots[3] != 0x80 { + t.Fatalf("link dots = %v", link.Dots) + } +} + +func (recorder frameRecorder) EnqueueFrame(frame braille.Frame) { recorder.frames <- frame } + +func TestBrailleFocusFrameTracksUnicodeCaret(t *testing.T) { + snapshot := atspi.NewSnapshot("/entry", "Message", "entry", "", "é猫x") + snapshot.TextCaretKnown = true + snapshot.BrailleLine = "é猫x" + snapshot.TextBeforeCaret = "é猫" + snapshot.BrailleCaret = 2 + if got := brailleFocusFrame(snapshot); !reflect.DeepEqual(got, braille.Frame{Text: "Message é猫x $l", Cursor: 10}) { + t.Fatalf("braille frame = %+v", got) + } + snapshot.BrailleLine = "a é" + snapshot.TextBeforeCaret = "a " + snapshot.BrailleCaret = 3 + if got := brailleFocusFrame(snapshot); !reflect.DeepEqual(got, braille.Frame{Text: "Message a é $l", Cursor: 11}) { + t.Fatalf("braille spacing/caret = %+v", got) + } + snapshot.BrailleLine = "é猫x" + snapshot.TextBeforeCaret = "other" + snapshot.BrailleCaret = 5 + if got := brailleFocusFrame(snapshot); got.Cursor != -1 { + t.Fatalf("invalid caret should be hidden: %+v", got) + } + snapshot.Role = "password text" + if got := brailleFocusFrame(snapshot); got.Cursor != 0 || got.Text != "Message password text" { + t.Fatalf("protected text leaked into braille: %+v", got) + } +} + +func TestBrailleFramesFollowAcceptedFocusAndCaretWithoutSpeech(t *testing.T) { + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + frames := make(chan braille.Frame, 8) + queue := outputQueue{speech: discardSpeech{}, braille: frameRecorder{frames: frames}} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + entry := atspi.NewSnapshot("/entry", "Message", "entry", "", "é猫") + entry.Editable = true + entry.TextCaretKnown = true + entry.BrailleLine = "é猫" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry) + wantFrame(t, frames, braille.Frame{Text: "Message é猫 $l", Cursor: 8}) + entry.TextBeforeCaret = "é" + entry.BrailleCaret = 1 + adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry) + wantFrame(t, frames, braille.Frame{Text: "Message é猫 $l", Cursor: 9}) + entry.Text = "é猫x" + entry.BrailleLine = "é猫x" + entry.TextBeforeCaret = "é猫x" + entry.BrailleCaret = 3 + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "x") + wantFrame(t, frames, braille.Frame{Text: "Message é猫x $l", Cursor: 11}) + // State snapshots may omit text capture. They must retain the displayed + // line while updating its state suffix. + entry.TextCaretKnown = false + entry.BrailleLine = "" + entry.Required = true + adapter.events <- atspi.NewEvent("object:state-changed:required", entry) + wantFrame(t, frames, braille.Frame{Text: "Message é猫x $l required", Cursor: 11}) + check := atspi.NewSnapshot("/check", "Save", "check box", "", "") + adapter.events <- atspi.NewEvent(atspi.FocusEvent, check) + wantFrame(t, frames, braille.Frame{Text: "< > Save check box", Cursor: 0}) + check.Checked = true + adapter.events <- atspi.NewEvent("object:state-changed:checked", check) + wantFrame(t, frames, braille.Frame{Text: " Save check box", Cursor: 0}) +} + +func wantFrame(t *testing.T, frames <-chan braille.Frame, want braille.Frame) { + t.Helper() + select { + case got := <-frames: + if !reflect.DeepEqual(got, want) { + t.Fatalf("braille frame = %+v, want %+v", got, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for braille frame %+v", want) + } +} + +func TestBraillePythonControlFormats(t *testing.T) { + for _, tc := range []struct { + name string + snapshot atspi.Snapshot + text string + cursor int + }{ + {"unchecked", atspi.Snapshot{Name: "Save", Role: "check box"}, "< > Save check box", 0}, + {"checked", atspi.Snapshot{Name: "Save", Role: "check box", Checked: true}, " Save check box", 0}, + {"mixed", atspi.Snapshot{Name: "Save", Role: "check box", Indeterminate: true}, "<-> Save check box", 0}, + {"radio", atspi.Snapshot{Name: "First", Role: "radio button", Checked: true}, "&=y First radio button", 0}, + {"toggle", atspi.Snapshot{Name: "Bold", Role: "toggle button", Checked: true}, "&=y Bold toggle button", 0}, + {"button", atspi.Snapshot{Name: "More", Role: "push button", Expandable: true}, "More collapsed push button", 0}, + {"menu", atspi.Snapshot{Name: "Save", Role: "menu item", SensitiveKnown: true}, "Save grayed", 0}, + {"combo", atspi.Snapshot{Name: "Color", Role: "combo box", ValueText: "Blue"}, "Color Blue combo box", 6}, + {"slider", atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, CurrentValue: 1.25, MinimumIncrement: 0.01}, "Volume 1.25 slider", 0}, + {"progress", atspi.Snapshot{Name: "Copy", Role: "progress bar", ValueKnown: true, CurrentValue: 25, MaximumValue: 100}, "Copy 25% progress bar", 0}, + {"progress range", atspi.Snapshot{Name: "Copy", Role: "progress bar", ValueKnown: true, MinimumValue: 10, CurrentValue: 20, MaximumValue: 30}, "Copy 50% progress bar", 0}, + {"tree", atspi.Snapshot{Name: "Files", Role: "tree item", Expandable: true, Expanded: true}, "Files expanded", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + frame := brailleFocusFrame(tc.snapshot) + if frame.Text != tc.text || frame.Cursor != tc.cursor { + t.Fatalf("got %+v, want %q cursor %d", frame, tc.text, tc.cursor) + } + }) + } +} + +func TestBrailleTextRoutesExcludeLabelsAndStates(t *testing.T) { + s := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Name: "Message", Role: "entry", Editable: true, TextCaretKnown: true, + BrailleLine: "é猫\n", BrailleLineStart: 20, BrailleCaret: 22, Invalid: true, Required: true, ReadOnly: true} + f := brailleFocusFrame(s) + if f.Text != "Message é猫 $l invalid required rdonly" || f.Cursor != 10 || len(f.Routes) != 1 { + t.Fatalf("frame %+v", f) + } + r := f.Routes[0] + if r.Start != 8 || r.End != 13 || r.SourceStart != 20 || r.TextLength != 2 || r.SourceText != "é猫\n" { + t.Fatalf("route %+v", r) + } + s.Role = "terminal" + f = brailleFocusFrame(s) + if f.Text != "é猫 " || f.Cursor != 2 { + t.Fatalf("terminal frame %+v", f) + } + s.BrailleCaret = 19 + if f = brailleFocusFrame(s); f.Cursor != -1 { + t.Fatalf("caret outside captured suffix: %+v", f) + } +} + +type tableBrailleCapturer struct{ *documentLineCapturer } + +type browseBrailleCapturer struct { + *documentLineCapturer + text string +} + +func (capturer *browseBrailleCapturer) ReadDocumentCaret(_ context.Context, _ atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + characters := []rune(capturer.text) + if offset < 0 || offset >= len(characters) { + return atspi.DocumentCaretText{CharacterCount: len(characters)}, nil + } + start, end := offset, offset+1 + if unit != atspi.DocumentCaretCharacter { + for start > 0 && characters[start-1] != '\n' { + start-- + } + for end < len(characters) && characters[end-1] != '\n' { + end++ + } + } + return atspi.DocumentCaretText{Start: start, End: end, Text: string(characters[start:end]), CharacterCount: len(characters)}, nil +} + +func TestBrowseCaretUpdatesBrailleWithoutNativeCaretEvents(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + capturer := &browseBrailleCapturer{documentLineCapturer: &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)}, text: "é猫 word\nSecond line"} + worker, err := review.NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + settings := config.DefaultSettings() + settings.EnableBraille = false + prefsWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + prefsWorker.braille = &recordingBrailleSettings{} + prefsWorker.documentReview = worker + values := preferences.FromSettings(settings) + values.EnableBraille = true + if response := prefsWorker.apply(ctx, preferences.Request{Values: values}); !response.Accepted { + t.Fatalf("enabling braille for the running session failed: %+v", response) + } + if err = worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + frames := make(chan braille.Frame, 16) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: discardSpeech{}, braille: frameRecorder{frames: frames}}, adapter, nil, worker, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + doc := atspi.Snapshot{BusName: ":1.42", Path: "/doc", Role: "document web", StatesKnown: true} + text := atspi.Snapshot{BusName: ":1.42", Path: "/text", Role: "static", StatesKnown: true, CharacterCount: utf8.RuneCountInString(capturer.text)} + tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{text.ID()}}, {Snapshot: text}}) + if err != nil { + t.Fatal(err) + } + adapter.events <- atspi.NewEvent(atspi.FocusEvent, doc) + wantFrame(t, frames, brailleFocusFrame(doc)) + for index, step := range []struct { + command review.DocumentCaretCommand + line string + cursor int + }{ + {review.CaretDocumentStart, "é猫 word", 0}, + {review.CaretNextCharacter, "é猫 word", 1}, + {review.CaretLineEnd, "é猫 word", 8}, + {review.CaretNextLine, "Second line", 0}, + {review.CaretPreviousLine, "é猫 word", 0}, + {review.CaretDocumentEnd, "Second line", 10}, + } { + if err = worker.MoveDocumentCaret(step.command); err != nil { + t.Fatal(err) + } + if index == 0 { + select { + case call := <-capturer.calls: + call.result <- tree + case <-time.After(time.Second): + t.Fatal("document capture missing") + } + } + select { + case frame := <-frames: + if strings.TrimRight(frame.Text, " ") != step.line || frame.Cursor != step.cursor || len(frame.Routes) != 1 || frame.Document == nil || frame.Routes[0].Path != text.Path { + t.Fatalf("command %v: frame %+v, want %q cursor %d with document routing", step.command, frame, step.line, step.cursor) + } + case <-time.After(time.Second): + t.Fatalf("command %v did not update braille", step.command) + } + } +} + +func TestBrowseBrailleUsesVirtualOffsetAndAncestorIndicators(t *testing.T) { + options := config.DefaultSettings().Braille + options.LinkIndicator = 0x80 + options.WordWrap = true + result := review.Result{DocumentPosition: review.DocumentPosition{Offset: 12, Snapshot: atspi.Snapshot{Role: "static"}}, + DocumentBrailleLine: &atspi.DocumentCaretText{Start: 10, Text: "é猫 word\n"}, + DocumentCaretContext: []atspi.Snapshot{{Role: "link"}, {Role: "heading", HeadingLevel: 2}}} + frame := brailleDocumentFrame(result, options) + if frame.Text != "é猫 word h2" || frame.Cursor != 2 || !frame.WordWrap || len(frame.Routes) != 0 || len(frame.Dots) != 11 || frame.Dots[0] != 0x80 || frame.Dots[8] != 0 { + t.Fatalf("browse frame = %+v", frame) + } + result.DocumentPosition.Snapshot = atspi.Snapshot{Role: "password text", Name: "Secret"} + frame = brailleDocumentFrame(result, options) + if frame.Text != "Secret password text" || len(frame.Routes) != 0 { + t.Fatalf("protected document text leaked: %+v", frame) + } +} + +func TestBrowseBrailleFragmentLineKeepsNativeCursor(t *testing.T) { + first := atspi.NewSnapshot("/first", "", "static", "", "") + first.BusName = ":1.999" + second := atspi.NewSnapshot("/second", "", "static", "", "") + second.BusName = first.BusName + result := review.Result{ + DocumentPosition: review.DocumentPosition{Snapshot: second, Offset: 8}, + DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: first, Start: 0, End: 2, RawText: "é猫"}, + {Snapshot: second, Start: 7, End: 11, RawText: "word", Separator: " "}, + }}, + DocumentCaretContext: []atspi.Snapshot{{Role: "heading", HeadingLevel: 2}}, + } + frame := brailleDocumentFrame(result, config.DefaultSettings().Braille) + if frame.Text != "é猫 word h2" || frame.Cursor != 4 || len(frame.Routes) != 0 { + t.Fatalf("fragment frame = %+v", frame) + } +} + +func TestLinkedHeadingBrailleKeepsHeadingAndLinkIndicators(t *testing.T) { + options := config.DefaultSettings().Braille + options.LinkIndicator = 0x80 + heading := atspi.NewSnapshot("/heading", "Linked heading", "heading", "", "") + heading.HeadingLevel = 2 + link := atspi.NewSnapshot("/link", "Linked heading", "link", "", "") + link.Visited = true + result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: heading, LinkedHeading: &link}} + frame := brailleDocumentFrame(result, options) + if !strings.Contains(frame.Text, "h2") || !strings.Contains(frame.Text, "vlnk") || len(frame.Dots) != utf8.RuneCountInString(frame.Text) || frame.Dots[0] != 0x80 || frame.Dots[len([]rune(heading.Name))] != 0 { + t.Fatalf("linked heading frame = %+v", frame) + } + result.DocumentBrailleLine = &atspi.DocumentCaretText{Text: "Linked heading"} + frame = brailleDocumentFrame(result, options) + if frame.Text != "Linked heading h2 vlnk" || frame.Dots[0] != 0x80 || frame.Dots[len([]rune(heading.Name))] != 0 { + t.Fatalf("linked heading line frame = %+v", frame) + } +} + +func (*tableBrailleCapturer) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + return atspi.DocumentCaretText{}, nil +} + +func TestTableNavigationUpdatesBrailleWithoutNativeCaretEvent(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + capturer := &tableBrailleCapturer{&documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)}} + worker, err := review.NewWorker(capturer, time.Second) + if err != nil { + t.Fatal(err) + } + if err = worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + frames := make(chan braille.Frame, 8) + queue := outputQueue{speech: discardSpeech{}, braille: frameRecorder{frames: frames}} + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + doc := atspi.Snapshot{BusName: ":1.42", Path: "/doc", Role: "document web", StatesKnown: true} + table := atspi.Snapshot{BusName: ":1.42", Path: "/table", Role: "table", Name: "Probe", TableRowCount: 1, TableColumnCount: 1, StatesKnown: true} + cell := atspi.Snapshot{BusName: ":1.42", Path: "/cell", Role: "table cell", Name: "Cell contents", TablePositionKnown: true, TableRowSpan: 1, TableColumnSpan: 1, StatesKnown: true} + tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{table.ID()}}, {Snapshot: table, Children: []atspi.ObjectID{cell.ID()}}, {Snapshot: cell}}) + if err != nil { + t.Fatal(err) + } + adapter.events <- atspi.NewEvent(atspi.FocusEvent, table) + wantFrame(t, frames, brailleFocusFrame(table)) + if err = worker.MoveDocumentCaret(review.TableFirst); err != nil { + t.Fatal(err) + } + select { + case call := <-capturer.calls: + call.result <- tree + case <-time.After(time.Second): + t.Fatal("table capture missing") + } + wantFrame(t, frames, brailleFocusFrame(cell)) +} diff --git a/internal/app/braille_routing.go b/internal/app/braille_routing.go new file mode 100644 index 0000000..1e9fc64 --- /dev/null +++ b/internal/app/braille_routing.go @@ -0,0 +1,79 @@ +package app + +import ( + "context" + "time" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" +) + +type brailleRouter interface { + RouteBraille(context.Context, atspi.BrailleRoute) error +} + +func (worker *brailleWorker) route(ctx context.Context, event braille.RoutingEvent) { + worker.mu.Lock() + frame := worker.frame + length := utf8.RuneCountInString(frame.Text) + if worker.secure || worker.stopping || frame.ID == 0 || event.ID != frame.ID || event.Offset < 0 || + (frame.Virtual == nil && event.Offset >= length) || (frame.Virtual != nil && event.Offset > length) { + worker.mu.Unlock() + return + } + if route := frame.Virtual; route != nil { + if worker.ocr == nil || event.Offset < route.Start || event.Offset > route.End { + worker.mu.Unlock() + return + } + routeCtx, cancel := context.WithTimeout(ctx, time.Second) + worker.routeCancel = cancel + worker.mu.Unlock() + defer cancel() + worker.ocr.RouteBraille(routeCtx, route.Generation, route.SourceStart+event.Offset-route.Start) + return + } + if worker.router == nil && frame.Document == nil { + worker.mu.Unlock() + return + } + var target *braille.TextRoute + for _, route := range frame.Routes { + if event.Offset >= route.Start && event.Offset < route.End { + target = &route + break + } + } + if target == nil { + worker.mu.Unlock() + return + } + routeCtx, cancel := context.WithTimeout(ctx, time.Second) + worker.routeCancel = cancel + worker.mu.Unlock() + defer cancel() + if document := frame.Document; document != nil { + if worker.document == nil { + return + } + worker.logger.Debug("routing document braille", "frame_id", frame.ID, "document_epoch", document.Epoch, + "source_bus", target.BusName, "source_path", target.Path, "source_start", target.SourceStart, + "native_offset", target.SourceStart+min(event.Offset-target.Start, target.TextLength)) + err := worker.document.RouteDocumentBraille(routeCtx, document.Epoch, + atspi.NewObjectID(document.FocusBus, document.FocusPath), atspi.NewObjectID(document.DocumentBus, document.DocumentPath), + atspi.NewObjectID(target.BusName, target.Path), target.SourceText, target.SourceStart, + target.SourceStart+min(event.Offset-target.Start, target.TextLength)) + if err != nil && routeCtx.Err() == nil { + worker.logger.Debug("document braille routing rejected", "error", err) + } + return + } + err := worker.router.RouteBraille(routeCtx, atspi.BrailleRoute{ + Focus: atspi.NewObjectID(target.BusName, target.Path), Line: target.SourceText, Start: target.SourceStart, + Offset: target.SourceStart + min(event.Offset-target.Start, target.TextLength), + }) + if err != nil && routeCtx.Err() == nil { + worker.logger.Debug("braille routing rejected", "error", err) + } +} diff --git a/internal/app/braille_session.go b/internal/app/braille_session.go new file mode 100644 index 0000000..f43dee1 --- /dev/null +++ b/internal/app/braille_session.go @@ -0,0 +1,161 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os/exec" + "strings" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func (worker *brailleWorker) run(ctx context.Context, done chan struct{}) { + defer close(done) + for ctx.Err() == nil { + if err := worker.session(ctx); err != nil && ctx.Err() == nil { + worker.logger.Warn("braille helper disconnected", "error", err) + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +func (worker *brailleWorker) session(ctx context.Context) (result error) { + defer func() { worker.mu.Lock(); worker.connected = false; worker.mu.Unlock() }() + childCtx, cancel := context.WithCancel(ctx) + defer cancel() + command := exec.CommandContext(childCtx, worker.path, "--client") + stdin, err := command.StdinPipe() + if err != nil { + return err + } + stdout, err := command.StdoutPipe() + if err != nil { + return err + } + var stderr bytes.Buffer + command.Stderr = &stderr + if err := command.Start(); err != nil { + return err + } + waitDone := make(chan struct{}) + var waitErr error + go func() { defer close(waitDone); waitErr = command.Wait() }() + var ioWorkers sync.WaitGroup + // Kill the disposable native owner, close pipes to unblock I/O, then join + // every goroutine before permitting reconnect or a new configured session. + defer func() { + cancel() + _ = stdin.Close() + _ = stdout.Close() + <-waitDone + ioWorkers.Wait() + if result != nil { + if detail := strings.TrimSpace(stderr.String()); detail != "" { + result = fmt.Errorf("%w: %s", result, detail) + } else if waitErr != nil { + result = fmt.Errorf("%w: %v", result, waitErr) + } + } + }() + ready := make(chan error, 1) + readErrors := make(chan error, 1) + // Routing keys retain FIFO order. Backpressure stops this pipe reader at + // 16 pending keys; cancellation releases it and retires the session queue. + routes := make(chan braille.RoutingEvent, 16) + ioWorkers.Add(1) + go func() { + defer ioWorkers.Done() + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 1024), 1024) + if !scanner.Scan() || scanner.Text() != "ready" { + ready <- errors.New("braille helper sent an invalid ready message") + return + } + ready <- nil + for scanner.Scan() { + var event braille.RoutingEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + readErrors <- errors.New("invalid braille routing event") + return + } + select { + case routes <- event: + case <-childCtx.Done(): + return + } + } + err := scanner.Err() + if err == nil { + err = io.EOF + } + readErrors <- err + }() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + select { + case err := <-ready: + if err != nil { + return err + } + case <-timer.C: + return errors.New("braille helper connection timed out") + case <-ctx.Done(): + return nil + } + worker.logger.Info("braille helper connected") + worker.mu.Lock() + worker.connected = true + worker.mu.Unlock() + writeError := make(chan error, 1) + ioWorkers.Add(1) + go func() { + defer ioWorkers.Done() + encoder := json.NewEncoder(stdin) + for { + frame := worker.current() + if err := encoder.Encode(frame); err != nil { + writeError <- err + return + } + // This confirms the helper write, not the physical display. Text is + // the uncontracted line; cursor offsets count Unicode characters. + if worker.logger.Enabled(context.Background(), slog.LevelDebug) { + worker.logger.Debug("sent braille frame", "frame_id", frame.ID, + "braille_text", frame.Text, "cursor_offset", frame.Cursor, + "contracted", frame.Contract, "table", frame.Table, + "word_wrap", frame.WordWrap, "expand_at_cursor", frame.ExpandAtCursor) + } + select { + case <-childCtx.Done(): + return + case <-worker.wake: + } + } + }() + for { + select { + case <-waitDone: + return waitErr + case err := <-writeError: + return err + case err := <-readErrors: + return err + case event := <-routes: + worker.route(childCtx, event) + case <-ctx.Done(): + return nil + } + } +} diff --git a/internal/app/braille_text_attributes.go b/internal/app/braille_text_attributes.go new file mode 100644 index 0000000..6f7ff56 --- /dev/null +++ b/internal/app/braille_text_attributes.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "strings" + "unicode/utf8" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func markTextAttributeRun(frame *braille.Frame, run atspi.TextAttributeRun, sourceStart, frameStart, textLength int, settings config.TextAttributeSettings) { + if settings.Indicator == 0 || run.End <= run.Start || textLength <= 0 { + return + } + marked := false + for _, choice := range settings.Choices { + if !choice.Braille { + continue + } + if choice.Key == "text-spelling" { + if atspi.TextAttributesMisspelled(run.Values) { + marked = true + break + } + continue + } + value := strings.TrimSpace(run.Values[choice.Key]) + if value == "" { + continue + } + for _, known := range characterAttributeOrder { + if known.key != choice.Key { + continue + } + if value != known.ignore { + marked = true + } + break + } + if marked { + break + } + } + if !marked { + return + } + begin := max(run.Start, sourceStart) + end := min(run.End, sourceStart+textLength) + if begin >= end { + return + } + count := utf8.RuneCountInString(frame.Text) + if len(frame.Dots) < count { + frame.Dots = append(frame.Dots, make([]byte, count-len(frame.Dots))...) + } + for offset := begin; offset < end; offset++ { + frame.Dots[frameStart+offset-sourceStart] |= byte(settings.Indicator) + } +} diff --git a/internal/app/braille_text_attributes_test.go b/internal/app/braille_text_attributes_test.go new file mode 100644 index 0000000..926d110 --- /dev/null +++ b/internal/app/braille_text_attributes_test.go @@ -0,0 +1,114 @@ +package app + +import ( + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestSpellingAttributesAgreeInSpeechAndBraille(t *testing.T) { + settings := config.DefaultTextAttributes() + settings.Indicator = 0x40 + for index := range settings.Choices { + settings.Choices[index].Speak = settings.Choices[index].Key == "text-spelling" + settings.Choices[index].Braille = settings.Choices[index].Speak + } + for _, test := range []struct { + key, value string + misspelled bool + }{ + {"invalid", "spelling", true}, + {"invalid", "grammar", false}, + {"invalid-spelling", "true", true}, + {"invalid-spelling", "misspelled", true}, + {"spelling", "true", true}, + {"spelling", "misspelled", true}, + {"text-spelling", "misspelled", true}, + {"text-spelling", "true", true}, + {"text-spelling", "false", false}, + {"text-spelling", "none", false}, + {"underline", "error", true}, + {"underline", "spelling", true}, + {"underline", "single", false}, + } { + t.Run(test.key+"="+test.value, func(t *testing.T) { + values := map[string]string{test.key: test.value} + snapshot := atspi.Snapshot{Role: "entry", BrailleLine: "word", TextCaretKnown: true, + TextAttributeRun: atspi.TextAttributeRun{Start: 0, End: 4, Values: values}} + frame := brailleFocusFrameWithOptions(snapshot, config.DefaultSettings().Braille, settings) + marked := len(frame.Dots) >= 4 && frame.Dots[0] == 0x40 && frame.Dots[3] == 0x40 + if marked != test.misspelled { + t.Errorf("braille dots = %v, want misspelled %v", frame.Dots, test.misspelled) + } + want := "" + if test.misspelled { + want = "misspelled" + } + if got := strings.Join(spokenCharacterAttributes(values, true, settings), ", "); got != want { + t.Errorf("speech = %q, want %q", got, want) + } + }) + } +} + +func TestBrailleTextAttributeIndicatorMarksCurrentRun(t *testing.T) { + s := atspi.Snapshot{Role: "entry", BrailleLine: "abcde", BrailleLineStart: 10, + BrailleCaret: 12, TextCaretKnown: true, + TextAttributeRun: atspi.TextAttributeRun{Start: 11, End: 14, Values: map[string]string{"weight": "700"}}} + settings := config.DefaultTextAttributes() + settings.Indicator = 0x40 + for index := range settings.Choices { + settings.Choices[index].Braille = settings.Choices[index].Key == "weight" + } + frame := brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille, settings) + if len(frame.Dots) < 5 || frame.Dots[0] != 0 || frame.Dots[1] != 0x40 || frame.Dots[2] != 0x40 || frame.Dots[3] != 0x40 || frame.Dots[4] != 0 { + t.Fatalf("attribute dots = %v", frame.Dots) + } + settings.Indicator = 0x80 + frame = brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille, settings) + if frame.Dots[2] != 0x80 { + t.Fatalf("dot eight = %v", frame.Dots) + } + settings.Choices[2].Braille = false + frame = brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille, settings) + if len(frame.Dots) != 0 { + t.Fatalf("disabled indicator = %v", frame.Dots) + } +} + +func TestBrailleTextAttributeIndicatorMarksSeparateRuns(t *testing.T) { + s := atspi.Snapshot{Role: "entry", BrailleLine: "abcd", BrailleLineStart: 4, + BrailleCaret: 5, TextCaretKnown: true, + TextAttributeRuns: []atspi.TextAttributeRun{ + {Start: 4, End: 6, Values: map[string]string{"weight": "700"}}, + {Start: 6, End: 8, Values: map[string]string{"underline": "single"}}, + }} + settings := config.DefaultTextAttributes() + settings.Indicator = 0xc0 + for index := range settings.Choices { + settings.Choices[index].Braille = settings.Choices[index].Key == "underline" + } + frame := brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille, settings) + if len(frame.Dots) < 4 || frame.Dots[0] != 0 || frame.Dots[1] != 0 || frame.Dots[2] != 0xc0 || frame.Dots[3] != 0xc0 { + t.Fatalf("separate runs = %v", frame.Dots) + } +} + +func TestCaptureTextAttributesRequiresEnabledBrailleAndMarkedAttribute(t *testing.T) { + settings := config.DefaultSettings() + settings.TextAttributes.Indicator = 0x40 + settings.TextAttributes.Choices[0].Braille = true + if captureTextAttributes(settings) { + t.Fatal("captured attributes with braille disabled") + } + settings.EnableBraille = true + if !captureTextAttributes(settings) { + t.Fatal("did not capture enabled attribute runs") + } + settings.TextAttributes.Choices[0].Braille = false + if captureTextAttributes(settings) { + t.Fatal("captured without a marked attribute") + } +} diff --git a/internal/app/braille_worker.go b/internal/app/braille_worker.go new file mode 100644 index 0000000..5dd67f6 --- /dev/null +++ b/internal/app/braille_worker.go @@ -0,0 +1,125 @@ +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// brailleWorker keeps all BrlAPI calls in a disposable child process. Only the +// newest frame is retained; focus and input callbacks never wait for the display. +// mu owns frames, flash generations, routing cancellation and session flags. +// The session joins its child and pipe workers before reconnecting; routing +// events retain their own bounded FIFO and validate the current frame identity. +type brailleWorker struct { + internalUIFrame *braille.Frame + router brailleRouter + document *review.Worker + ocr *ocrSession + sequence uint64 + routeCancel context.CancelFunc + secure bool + stopping bool + connected bool + logger *slog.Logger + path string + mu sync.Mutex + frame braille.Frame + baseFrame braille.Frame + flashTimer *time.Timer + flashGeneration uint64 + flashContext context.Context // A tentacle flash expires with its command/session. + protectedFlash bool // Focus updates retain baseFrame until expiry or user input. + contracted bool + wordWrap bool + table string + wake chan struct{} + cancel context.CancelFunc + done chan struct{} +} + +func newBrailleWorker(logger *slog.Logger) (*brailleWorker, error) { + executable, err := os.Executable() + if err != nil { + return nil, err + } + return &brailleWorker{logger: logger, path: filepath.Join(filepath.Dir(executable), "cthulhu-braille"), + frame: braille.Frame{Cursor: -1}, baseFrame: braille.Frame{Cursor: -1}, wake: make(chan struct{}, 1)}, nil +} + +func (worker *brailleWorker) Start() error { + worker.mu.Lock() + defer worker.mu.Unlock() + if worker.stopping { + return errors.New("braille helper shutdown is still pending") + } + if worker.cancel != nil { + return nil + } + info, err := os.Stat(worker.path) + if err != nil || info.IsDir() || info.Mode()&0111 == 0 { + return fmt.Errorf("braille helper %q is unavailable; build cthulhu-braille with -tags brlapi", worker.path) + } + ctx, cancel := context.WithCancel(context.Background()) + worker.cancel = cancel + worker.done = make(chan struct{}) + go worker.run(ctx, worker.done) + return nil +} + +func (worker *brailleWorker) Stop(ctx context.Context) error { + worker.mu.Lock() + cancel, done := worker.cancel, worker.done + if cancel == nil { + worker.mu.Unlock() + return nil + } + worker.stopping = true + if worker.routeCancel != nil { + worker.routeCancel() + } + worker.mu.Unlock() + cancel() + select { + case <-done: + worker.mu.Lock() + if worker.done == done { + worker.cancel, worker.done = nil, nil + worker.stopping = false + worker.connected = false + worker.protectedFlash = false + worker.flashContext = nil + worker.frame = braille.Frame{Cursor: -1} + worker.baseFrame = braille.Frame{Cursor: -1} + if worker.flashTimer != nil { + worker.flashTimer.Stop() + worker.flashTimer = nil + } + } + worker.mu.Unlock() + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (worker *brailleWorker) Configure(ctx context.Context, enabled bool) error { + if enabled { + return worker.Start() + } + return worker.Stop(ctx) +} + +func (worker *brailleWorker) Running() bool { + worker.mu.Lock() + defer worker.mu.Unlock() + return worker.cancel != nil && !worker.stopping && worker.connected +} diff --git a/internal/app/braille_worker_test.go b/internal/app/braille_worker_test.go new file mode 100644 index 0000000..80e03f1 --- /dev/null +++ b/internal/app/braille_worker_test.go @@ -0,0 +1,173 @@ +package app + +import ( + "context" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" + "log/slog" + "path/filepath" + "testing" + "time" +) + +func TestBrailleHelperStartsOnlyWhenEnabled(t *testing.T) { + worker := &brailleWorker{logger: slog.New(slog.DiscardHandler), + path: filepath.Join(t.TempDir(), "missing-helper"), wake: make(chan struct{}, 1)} + worker.Enqueue("focus") + if worker.frame.Text != "" { + t.Fatal("disabled braille retained a frame") + } + if err := worker.Configure(context.Background(), false); err != nil { + t.Fatal(err) + } + if err := worker.Configure(context.Background(), true); err == nil { + t.Fatal("enabled braille accepted a missing helper") + } +} + +func TestBrailleWordWrapUpdatesCurrentAndMessageFrames(t *testing.T) { + worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} + worker.EnqueueFrame(braille.Frame{Text: "Focused long line", Cursor: 0}) + worker.SetWordWrap(true) + if !worker.current().WordWrap || !worker.baseFrame.WordWrap { + t.Fatal("word wrap did not update current and restoration frames") + } + if !worker.EnqueueMessage("Long external message") || !worker.current().WordWrap { + t.Fatal("external message lost word wrapping") + } + options := config.DefaultSettings().Braille + options.WordWrap, options.FlashPersistent = true, true + if !worker.Flash("Long flash message", options) || !worker.current().WordWrap { + t.Fatal("flash message lost word wrapping") + } + worker.SetWordWrap(false) + if worker.current().WordWrap || worker.baseFrame.WordWrap { + t.Fatal("disabled word wrap did not update current and restoration frames") + } +} + +func TestBrailleFlashRestoresFocusAndHonorsPreferences(t *testing.T) { + worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} + worker.EnqueueFrame(braille.Frame{Text: "focus", Cursor: 0}) + options := config.DefaultSettings().Braille + options.FlashTime = 10 + if !worker.Flash("notice", options) || worker.current().Text != "notice" { + t.Fatalf("flash frame = %+v", worker.current()) + } + deadline := time.After(time.Second) + for worker.current().Text != "focus" { + select { + case <-deadline: + t.Fatal("flash did not restore focus") + case <-time.After(time.Millisecond): + } + } + options.FlashMessages = false + if worker.Flash("ignored", options) || worker.current().Text != "focus" { + t.Fatal("disabled flash changed the display") + } + options.FlashMessages = true + options.FlashPersistent = true + if !worker.Flash("persistent", options) { + t.Fatal("persistent flash rejected") + } + time.Sleep(20 * time.Millisecond) + if worker.current().Text != "persistent" { + t.Fatal("persistent flash expired") + } + worker.EnqueueFrame(braille.Frame{Text: "new focus", Cursor: -1}) + if worker.current().Text != "new focus" { + t.Fatal("focus did not replace persistent flash") + } +} + +type recordingBrailleRouter struct { + calls chan atspi.BrailleRoute + entered chan struct{} +} + +func (r *recordingBrailleRouter) RouteBraille(ctx context.Context, route atspi.BrailleRoute) error { + r.calls <- route + if r.entered != nil { + close(r.entered) + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func TestBrailleWorkerRoutesOnlyCurrentTextCells(t *testing.T) { + router := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 8)} + worker := &brailleWorker{router: router, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}} + snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Name: "Message", Role: "entry", Editable: true, + TextCaretKnown: true, BrailleLine: "é猫", BrailleLineStart: 20, BrailleCaret: 21, Required: true} + worker.EnqueueFrame(brailleFocusFrame(snapshot)) + id := worker.current().ID + for _, event := range []braille.RoutingEvent{{ID: id, Offset: 0}, {ID: id, Offset: 13}, {ID: id, Offset: -1}, {ID: id + 1, Offset: 8}} { + worker.route(context.Background(), event) + } + if len(router.calls) != 0 { + t.Fatal("routed label, suffix, invalid offset or stale frame") + } + for _, offset := range []int{8, 9, 10, 12} { + worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: offset}) + } + for _, want := range []int{20, 21, 22, 22} { + select { + case got := <-router.calls: + if got.Offset != want || got.Focus != snapshot.ID() || got.Line != "é猫" { + t.Fatalf("route %+v want offset %d", got, want) + } + default: + t.Fatal("routing missing") + } + } + worker.EnqueueFrame(braille.Frame{Cursor: -1}) + worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: 8}) + if len(router.calls) != 0 { + t.Fatal("routed cleared focus") + } +} + +func TestBrailleWorkerCancelsRoutingWhenFrameChanges(t *testing.T) { + router := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 1), entered: make(chan struct{})} + worker := &brailleWorker{router: router, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}} + worker.EnqueueFrame(braille.Frame{Text: "abc", Cursor: 0, Routes: []braille.TextRoute{{Start: 0, End: 3, TextLength: 3, BusName: ":1.42", Path: "/entry", SourceText: "abc"}}}) + done := make(chan struct{}) + go func() { + defer close(done) + worker.route(context.Background(), braille.RoutingEvent{ID: worker.current().ID, Offset: 1}) + }() + select { + case <-router.entered: + case <-time.After(time.Second): + t.Fatal("route did not start") + } + worker.EnqueueFrame(braille.Frame{Cursor: -1}) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("route was not canceled") + } +} + +func TestBrailleStopTimeoutRetainsJoinState(t *testing.T) { + done := make(chan struct{}) + worker := &brailleWorker{cancel: func() {}, done: done} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := worker.Stop(ctx); err != context.Canceled { + t.Fatalf("stop error %v", err) + } + if worker.cancel == nil || worker.done == nil || !worker.stopping { + t.Fatal("lost timed-out worker") + } + close(done) + if err := worker.Stop(context.Background()); err != nil { + t.Fatal(err) + } + if worker.cancel != nil || worker.done != nil { + t.Fatal("completed stop retained session") + } +} diff --git a/internal/app/browser_dynamic_test.go b/internal/app/browser_dynamic_test.go new file mode 100644 index 0000000..b12df1a --- /dev/null +++ b/internal/app/browser_dynamic_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeBrowserDynamicNavigation(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + page := `Dynamic navigation regression +Start of page + +

~! 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 z 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±e9U`1k6+ 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