diff --git a/src/cthulhu/backends/toml_backend.py b/src/cthulhu/backends/toml_backend.py index 98248ed..bdf3425 100644 --- a/src/cthulhu/backends/toml_backend.py +++ b/src/cthulhu/backends/toml_backend.py @@ -304,6 +304,13 @@ class Backend: def _migrateSettings(self, settingsDict): """Migrate old setting names to new ones.""" + migrationKey = 'wineAccessibilityPluginMigrated' + activePlugins = settingsDict.get('activePlugins') + if isinstance(activePlugins, list) and migrationKey not in settingsDict: + if 'WineAccessibility' not in activePlugins: + activePlugins.append('WineAccessibility') + settingsDict[migrationKey] = True + # Migration: enableSpeechIndentation -> enableIndentation if 'enableSpeechIndentation' in settingsDict and 'enableIndentation' not in settingsDict: settingsDict['enableIndentation'] = settingsDict['enableSpeechIndentation'] diff --git a/src/cthulhu/dbus_service.py b/src/cthulhu/dbus_service.py index 28a82c7..de1c908 100644 --- a/src/cthulhu/dbus_service.py +++ b/src/cthulhu/dbus_service.py @@ -25,6 +25,7 @@ import contextlib import enum import inspect import os +import time import types import typing import xml.etree.ElementTree as ET @@ -641,6 +642,8 @@ class _InterfaceBuilder: class CthulhuDBusServiceInterface(Publishable): """Internal D-Bus service object that handles D-Bus specifics.""" + _WINE_FOCUS_DEBOUNCE_MS = 50 + def for_publication(self): """Returns the D-Bus interface XML for publication.""" @@ -744,15 +747,92 @@ class CthulhuDBusServiceInterface(Publishable): ) -> bool: """Presents a normalized accessibility event received from Wine.""" - del sourceId, eventType, states + del states + eventType = eventType.casefold() + debug.print_message( + debug.LEVEL_INFO, + ( + "WINE ACCESS EVENT: " + f"type={eventType} source={sourceId} name={name!r} " + f"role={role!r} value={value!r} window={windowTitle!r}" + ), + True, + ) + if eventType == "focus": + self._wineAccessibleFocusSource = sourceId + else: + if eventType not in {"value", "selection"}: + return False + focusedSource = getattr(self, "_wineAccessibleFocusSource", None) + focusedWindow = getattr(self, "_wineAccessibleWindowTitle", None) + pendingFocus = getattr(self, "_wineAccessiblePendingFocus", None) + if focusedWindow is None and pendingFocus is not None: + focusedWindow = pendingFocus[2] + if sourceId != focusedSource: + if eventType != "selection": + return False + if not windowTitle or windowTitle != focusedWindow: + return False + + if role.casefold() == "client": + role = "" parts = [part for part in (name, role, value, description) if part] if position > 0 and count > 0: parts.append(f"{position} of {count}") - if windowTitle and windowTitle not in parts: - parts.append(windowTitle) - if not parts: + if not parts and not (eventType == "focus" and windowTitle): return False - return self.PresentMessage(", ".join(parts)) + + message = ", ".join(parts) + now = time.monotonic() + signature = (sourceId, message or windowTitle) + lastSignature = getattr(self, "_wineAccessibleLastSignature", None) + lastTime = getattr(self, "_wineAccessibleLastTime", 0.0) + if signature == lastSignature and now - lastTime < 0.1: + if eventType == "focus": + self._wineAccessiblePendingFocus = None + return False + + if eventType == "focus": + self._wineAccessiblePendingFocus = (signature, parts, windowTitle) + if not getattr(self, "_wineAccessibleFocusTimeoutId", 0): + self._wineAccessibleFocusTimeoutId = GLib.timeout_add( + self._WINE_FOCUS_DEBOUNCE_MS, + self._present_pending_accessible_focus, + ) + return True + + pending = getattr(self, "_wineAccessiblePendingFocus", None) + if pending is not None and ( + pending[0][0] == sourceId or (windowTitle and pending[2] == windowTitle) + ): + self._wineAccessiblePendingFocus = (signature, parts, pending[2]) + return True + + self._wineAccessibleLastSignature = signature + self._wineAccessibleLastTime = now + return self.PresentMessage(message) + + def _present_pending_accessible_focus(self) -> bool: + """Presents the final focus event after Wine's transient focus burst.""" + + pending = getattr(self, "_wineAccessiblePendingFocus", None) + self._wineAccessiblePendingFocus = None + self._wineAccessibleFocusTimeoutId = 0 + if pending is None: + return False + + signature, parts, windowTitle = pending + self._wineAccessibleLastSignature = signature + self._wineAccessibleLastTime = time.monotonic() + parts = list(parts) + if windowTitle: + previousTitle = getattr(self, "_wineAccessibleWindowTitle", None) + self._wineAccessibleWindowTitle = windowTitle + if windowTitle != previousTitle and windowTitle not in parts: + parts.append(windowTitle) + if parts: + self.PresentMessage(", ".join(parts)) + return False def GetVersion(self) -> str: # pylint: disable=invalid-name """Returns Cthulhu's version and revision if available.""" diff --git a/tests/test_dbus_service_native_interfaces.py b/tests/test_dbus_service_native_interfaces.py index 4a6d7df..9d4c844 100644 --- a/tests/test_dbus_service_native_interfaces.py +++ b/tests/test_dbus_service_native_interfaces.py @@ -156,8 +156,206 @@ class NativeRemoteControllerTest(unittest.TestCase): ) self.assertTrue(result) + present_message.assert_not_called() + interface._present_pending_accessible_focus() present_message.assert_called_once_with("Open, button, 2 of 4, Settings") + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_ignores_changes_from_unfocused_objects(self, present_message): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + interface.PresentAccessibleEvent( + "control-1", "focus", "Open", "button", "", "", "", 0, 0, "Settings" + ) + present_message.reset_mock() + + result = interface.PresentAccessibleEvent( + "control-2", "value", "Cancel", "button", "", "", "", 0, 0, "Settings" + ) + + self.assertFalse(result) + present_message.assert_not_called() + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_presents_focused_value_changes_without_repeating_title( + self, present_message + ): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + interface.PresentAccessibleEvent( + "control-1", "focus", "Version", "combo box", "Windows 10", "", "", 0, 0, + "Wine configuration" + ) + interface._present_pending_accessible_focus() + present_message.reset_mock() + + result = interface.PresentAccessibleEvent( + "control-1", "value", "Version", "combo box", "Windows 11", "", "", 0, 0, + "Wine configuration" + ) + + self.assertTrue(result) + present_message.assert_called_once_with("Version, combo box, Windows 11") + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_suppresses_immediate_duplicates(self, present_message): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + + first = interface.PresentAccessibleEvent( + "control-1", "focus", "OK", "button", "", "", "", 0, 0, "Settings" + ) + interface._present_pending_accessible_focus() + second = interface.PresentAccessibleEvent( + "control-1", "focus", "OK", "button", "", "", "", 0, 0, "Settings" + ) + + self.assertTrue(first) + self.assertFalse(second) + present_message.assert_called_once_with("OK, button, Settings") + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_focus_burst_presents_only_final_control(self, present_message): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + + interface.PresentAccessibleEvent( + "control-1", "focus", "Add application", "button", "", "", "", 0, 0, + "Wine configuration" + ) + interface.PresentAccessibleEvent( + "control-2", "focus", "OK", "button", "", "", "", 0, 0, + "Wine configuration" + ) + interface.PresentAccessibleEvent( + "control-1", "focus", "Add application", "button", "", "", "", 0, 0, + "Wine configuration" + ) + + present_message.assert_not_called() + interface._present_pending_accessible_focus() + present_message.assert_called_once_with( + "Add application, button, Wine configuration" + ) + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_focus_duplicate_clears_stale_pending_focus(self, present_message): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + interface.PresentAccessibleEvent( + "control-1", "focus", "Open", "button", "", "", "", 0, 0, + "Settings" + ) + interface._present_pending_accessible_focus() + present_message.reset_mock() + + interface.PresentAccessibleEvent( + "control-2", "focus", "Cancel", "button", "", "", "", 0, 0, + "Settings" + ) + duplicate = interface.PresentAccessibleEvent( + "control-1", "focus", "Open", "button", "", "", "", 0, 0, + "Settings" + ) + interface._present_pending_accessible_focus() + + self.assertFalse(duplicate) + self.assertEqual(interface._wineAccessibleFocusSource, "control-1") + present_message.assert_not_called() + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_focus_presents_new_window_title_without_control_fields( + self, present_message + ): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + + result = interface.PresentAccessibleEvent( + "window", "focus", "", "", "", "", "", 0, 0, "Wine configuration" + ) + interface._present_pending_accessible_focus() + + self.assertTrue(result) + present_message.assert_called_once_with("Wine configuration") + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_omits_generic_client_role(self, present_message): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + + interface.PresentAccessibleEvent( + "control-1", "focus", "OK", "client", "", "", "", 0, 0, "Settings" + ) + interface._present_pending_accessible_focus() + + present_message.assert_called_once_with("OK, Settings") + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_ignores_name_and_state_noise_on_focused_object( + self, present_message + ): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + interface.PresentAccessibleEvent( + "control-1", "focus", "Windows 10", "client", "", "", "", 0, 0, + "Wine configuration" + ) + interface._present_pending_accessible_focus() + present_message.reset_mock() + + name_result = interface.PresentAccessibleEvent( + "control-1", "name", "OK", "client", "", "", "", 0, 0, + "Wine configuration" + ) + state_result = interface.PresentAccessibleEvent( + "control-1", "state", "OK", "client", "", "focused", "", 0, 0, + "Wine configuration" + ) + + self.assertFalse(name_result) + self.assertFalse(state_result) + present_message.assert_not_called() + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_accepts_selection_from_focused_combo_child( + self, present_message + ): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + interface.PresentAccessibleEvent( + "combo", "focus", "Windows 10", "client", "", "", "", 0, 0, + "Wine configuration" + ) + interface._present_pending_accessible_focus() + present_message.reset_mock() + + result = interface.PresentAccessibleEvent( + "combo-item-2", "selection", "Windows 8.1", "client", "", "", "", 0, 0, + "Wine configuration" + ) + + self.assertTrue(result) + present_message.assert_called_once_with("Windows 8.1") + + @mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage") + def test_accessible_event_rejects_selection_from_other_window(self, present_message): + present_message.return_value = True + interface = dbus_service.CthulhuDBusServiceInterface() + interface.PresentAccessibleEvent( + "combo", "focus", "Windows 10", "client", "", "", "", 0, 0, + "Wine configuration" + ) + interface._present_pending_accessible_focus() + present_message.reset_mock() + + result = interface.PresentAccessibleEvent( + "other-item", "selection", "Unrelated", "client", "", "", "", 0, 0, + "Other window" + ) + + self.assertFalse(result) + present_message.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_toml_backend_legacy_schema_regressions.py b/tests/test_toml_backend_legacy_schema_regressions.py index 267b130..6e63791 100644 --- a/tests/test_toml_backend_legacy_schema_regressions.py +++ b/tests/test_toml_backend_legacy_schema_regressions.py @@ -64,7 +64,11 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase): settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP, ) self.assertEqual(general["cthulhuModifierKeys"], settings.DESKTOP_MODIFIER_KEYS) - self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"]) + self.assertEqual( + general["activePlugins"], + ["Clipboard", "OCR", "WineAccessibility"], + ) + self.assertTrue(general["wineAccessibilityPluginMigrated"]) self.assertEqual(general["aiProvider"], settings.AI_PROVIDER_OLLAMA) self.assertFalse(general["aiAssistantEnabled"]) self.assertEqual(general["ocrLanguageCode"], "eng") @@ -82,10 +86,35 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase): savedSettings = settingsPath.read_text(encoding="utf-8") self.assertIn('profile = ["Default", "default"]', savedSettings) - self.assertIn('activePlugins = ["Clipboard", "OCR"]', savedSettings) + self.assertIn( + 'activePlugins = ["Clipboard", "OCR", "WineAccessibility"]', + savedSettings, + ) + self.assertIn("wineAccessibilityPluginMigrated = true", savedSettings) self.assertNotIn("format-version = 2", savedSettings) self.assertNotIn("[profiles.default.metadata]", savedSettings) + def test_wine_accessibility_migration_respects_later_plugin_disable(self): + migratedSettings = """[general] +startingProfile = ["Default", "default"] + +[profiles.default] +profile = ["Default", "default"] +activePlugins = ["Clipboard", "OCR"] +wineAccessibilityPluginMigrated = true +""" + + with tempfile.TemporaryDirectory() as tempDir: + Path(tempDir, "user-settings.toml").write_text( + migratedSettings, + encoding="utf-8", + ) + + backend = Backend(tempDir) + general = backend.getGeneral("default") + + self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"]) + def test_legacy_profile_keybindings_are_preserved(self): legacySettings = LEGACY_SETTINGS.replace( 'desktop-modifier-keys = ["Insert", "KP_Insert"]', diff --git a/wine-access/main.cpp b/wine-access/main.cpp index 43ae481..71f08f3 100644 --- a/wine-access/main.cpp +++ b/wine-access/main.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -133,11 +134,33 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, wchar_t title[512] = {}; GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512); const std::string windowTitle = to_utf8(title); - const char *eventType = event == EVENT_OBJECT_FOCUS ? "focus" : "change"; + const std::string sourceId = + std::to_string(reinterpret_cast(window)) + ":" + + std::to_string(objectId) + ":" + std::to_string(childId); + const char *eventType = nullptr; + switch (event) { + case EVENT_OBJECT_FOCUS: + eventType = "focus"; + break; + case EVENT_OBJECT_NAMECHANGE: + eventType = "name"; + break; + case EVENT_OBJECT_VALUECHANGE: + eventType = "value"; + break; + case EVENT_OBJECT_SELECTION: + eventType = "selection"; + break; + case EVENT_OBJECT_STATECHANGE: + eventType = "state"; + break; + default: + return; + } call_boolean("PresentAccessibleEvent", - g_variant_new("(sssssssiis)", "msaa", eventType, name.c_str(), - roleText.c_str(), value.c_str(), stateText.c_str(), - description.c_str(), 0, + g_variant_new("(sssssssiis)", sourceId.c_str(), eventType, + name.c_str(), roleText.c_str(), value.c_str(), + stateText.c_str(), description.c_str(), 0, static_cast(childCount), windowTitle.c_str())); } @@ -253,8 +276,9 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { windowClass.hInstance = instance; windowClass.lpszClassName = L"wxWindowClassNR"; RegisterClassW(&windowClass); - window = CreateWindowW(windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0, - nullptr, nullptr, instance, nullptr); + window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, + 0, nullptr, nullptr, instance, nullptr); } if ((controllerEnabled && (window == nullptr || !register_rpc_server()))) { g_object_unref(connection);