diff --git a/README.md b/README.md index c2d26de..e0ed570 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit. - **Simple protocol**: `echo "text" | socat - UNIX-CLIENT:"${XDG_RUNTIME_DIR}/cthulhu.sock"` ### Wine And Proton -- **Standard controls**: Focus, name, value, role, state, and selection changes from MSAA controls +- **Standard controls**: MSAA events with Win32 class/message fallbacks for controls Wine leaves empty - **Unmodified clients**: Implements the NVDA Controller RPC endpoint used by the official controller DLLs - **Tolk compatibility**: Provides the NVDA detection window expected by the official `Tolk.dll` - **Prefix management**: Runs one helper per active same-user Wine or Proton prefix and preserves Proton's loader diff --git a/docs/wine-accessibility.md b/docs/wine-accessibility.md index f2343f1..ca3103f 100644 --- a/docs/wine-accessibility.md +++ b/docs/wine-accessibility.md @@ -10,15 +10,16 @@ The manager starts one helper per prefix, uses `WINEPREFIX` or `STEAM_COMPAT_DATA_PATH/pfx`, and passes the prefix's own loader through `WINELOADER`. Discovery requires an actual Wine loader process, so exported prefix variables and idle `wineserver` processes do not keep helpers alive. A helper is stopped after its prefix has been idle for ten seconds. Repeated early exits use exponential -restart backoff. If an actual NVDA window already exists in the prefix, the helper exits and yields -to it. +restart backoff. If an actual NVDA window already exists in the prefix, the helper yields the +controller endpoint to it while continuing to provide standard-dialog reading. ## Controller Compatibility The helper registers the current `NvdaCtlr..` local RPC endpoint, the legacy `NvdaCtlr` endpoint used by older official controller clients, and the Controller 1, 2, and 3 interface UUIDs. It also creates the `wxWindowClassNR` / `NVDA` window used -by official Tolk releases for detection. Therefore applications can keep the original +by official Tolk releases for detection. The compatibility window is explicitly hidden, +nonactivating, and excluded from ordinary window switching. Therefore applications can keep the original `nvdaControllerClient32.dll`, `nvdaControllerClient64.dll`, and `Tolk.dll`; no DLL override is required. @@ -33,10 +34,16 @@ The current Meson target builds the 64-bit helper. A 32-bit helper requires a 32 ## Standard Dialog Reading -The first reader stage listens for MSAA focus, name, value, state, and selection events. This makes -ordinary Win32 dialogs and controls useful. It does not expose custom-drawn, DirectX, or -game-specific interfaces. UI Automation coverage and an AT-SPI object broker remain separate later -stages; the current helper presents normalized events directly through Cthulhu. +The first reader stage listens for MSAA focus, name, value, state, and selection events. When Wine's +MSAA proxy has no useful data for a standard control, the helper falls back to the control's Win32 +class and message protocol. Combo boxes, list boxes, edits, buttons, sliders, and static controls can +therefore expose useful names, roles, values, and states without replacing the MSAA path for controls +which already read correctly. + +This class-based fallback is adapted from the LGPL `wine-a11y` reader published by Mudb0y. + +The reader does not expose custom-drawn, DirectX, or game-specific interfaces. UI Automation coverage +and an AT-SPI object broker remain separate later stages. ## Prism diff --git a/wine-access/main.cpp b/wine-access/main.cpp index 71f08f3..44b5d00 100644 --- a/wine-access/main.cpp +++ b/wine-access/main.cpp @@ -2,6 +2,7 @@ #include "nvdaController.h" +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include namespace { @@ -19,6 +21,16 @@ constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service"; constexpr int DBUS_TIMEOUT_MS = 500; GDBusConnection *connection = nullptr; +struct ControlDescription { + std::string name; + std::string role; + std::string value; + std::string states; + std::string description; + int position = 0; + int count = 0; +}; + std::string to_utf8(const wchar_t *text) { if (text == nullptr) return {}; @@ -94,6 +106,201 @@ std::string accessible_property( return text; } +// Standard-control queries follow the LGPL wine-a11y reader published by +// Mudb0y, while presentation remains on Cthulhu's D-Bus interface. +void tidy_text(wchar_t *text, bool label = false) { + wchar_t *output = text; + for (wchar_t *input = text; *input != L'\0'; ++input) { + if (*input != L'&') + *output++ = *input; + } + *output = L'\0'; + if (label && output > text && output[-1] == L':') + output[-1] = L'\0'; +} + +bool get_control_text(HWND window, wchar_t *text, int capacity) { + DWORD_PTR result = 0; + text[0] = L'\0'; + if (!SendMessageTimeoutW(window, WM_GETTEXT, capacity, + reinterpret_cast(text), SMTO_ABORTIFHUNG, + 200, &result)) + return false; + tidy_text(text); + return text[0] != L'\0'; +} + +void find_control_label(HWND window, wchar_t *text, int capacity) { + HWND previous = window; + wchar_t className[64] = {}; + text[0] = L'\0'; + for (int hop = 0; hop < 3; ++hop) { + previous = GetWindow(previous, GW_HWNDPREV); + if (previous == nullptr || + !GetClassNameW(previous, className, std::size(className))) + return; + if (wcscmp(className, L"Static") != 0 && wcscmp(className, L"Button") != 0) + continue; + if (get_control_text(previous, text, capacity)) + tidy_text(text, true); + return; + } +} + +std::string window_title(HWND window) { + wchar_t title[512] = {}; + GetWindowTextW(GetAncestor(window, GA_ROOT), title, std::size(title)); + tidy_text(title); + return to_utf8(title); +} + +std::string source_id(HWND window, const char *suffix) { + return std::to_string(reinterpret_cast(window)) + ":" + + suffix; +} + +bool present_accessible_event(const std::string &sourceId, + const char *eventType, + const ControlDescription &control, + const std::string &windowTitle) { + return call_boolean("PresentAccessibleEvent", + g_variant_new( + "(sssssssiis)", sourceId.c_str(), eventType, + control.name.c_str(), control.role.c_str(), + control.value.c_str(), control.states.c_str(), + control.description.c_str(), control.position, + control.count, windowTitle.c_str())) == ERROR_SUCCESS; +} + +ControlDescription describe_standard_control(HWND window) { + ControlDescription control; + wchar_t className[64] = {}; + wchar_t text[512] = {}; + wchar_t label[256] = {}; + if (!GetClassNameW(window, className, std::size(className))) + return control; + get_control_text(window, text, std::size(text)); + + if (wcscmp(className, L"Button") == 0) { + control.name = to_utf8(text); + const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + DWORD_PTR checked = 0; + switch (style & BS_TYPEMASK) { + case BS_CHECKBOX: + case BS_AUTOCHECKBOX: + case BS_3STATE: + case BS_AUTO3STATE: + control.role = "check box"; + SendMessageTimeoutW(window, BM_GETCHECK, 0, 0, SMTO_ABORTIFHUNG, 200, + &checked); + control.description = checked == BST_CHECKED ? "checked" + : checked == BST_INDETERMINATE ? "half checked" + : "not checked"; + break; + case BS_RADIOBUTTON: + case BS_AUTORADIOBUTTON: + control.role = "radio button"; + SendMessageTimeoutW(window, BM_GETCHECK, 0, 0, SMTO_ABORTIFHUNG, 200, + &checked); + control.description = checked == BST_CHECKED ? "checked" : "not checked"; + break; + case BS_GROUPBOX: + control.role = "grouping"; + break; + default: + control.role = "button"; + } + return control; + } + + const bool isEdit = wcscmp(className, L"Edit") == 0 || + wcscmp(className, L"RICHEDIT50W") == 0 || + wcscmp(className, L"RichEdit20W") == 0 || + wcscmp(className, L"RichEdit20A") == 0; + if (isEdit) { + find_control_label(window, label, std::size(label)); + control.name = to_utf8(label); + const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + control.role = style & ES_READONLY ? "read only edit" : "edit"; + if (style & ES_PASSWORD) + control.description = "password"; + else + control.value = to_utf8(text); + return control; + } + + if (wcscmp(className, L"ComboBox") == 0 || + wcscmp(className, L"ComboBoxEx32") == 0) { + find_control_label(window, label, std::size(label)); + control.name = to_utf8(label); + control.role = "combo box"; + control.value = to_utf8(text); + return control; + } + + if (wcscmp(className, L"ListBox") == 0 || + wcscmp(className, L"ComboLBox") == 0) { + find_control_label(window, label, std::size(label)); + control.name = to_utf8(label); + control.role = "list"; + DWORD_PTR selected = static_cast(-1); + DWORD_PTR count = 0; + DWORD_PTR length = 0; + SendMessageTimeoutW(window, LB_GETCOUNT, 0, 0, SMTO_ABORTIFHUNG, 200, + &count); + SendMessageTimeoutW(window, LB_GETCURSEL, 0, 0, SMTO_ABORTIFHUNG, 200, + &selected); + const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + const bool stringItems = + !(style & (LBS_OWNERDRAWFIXED | LBS_OWNERDRAWVARIABLE)) || + (style & LBS_HASSTRINGS); + if (static_cast(selected) >= 0) { + SendMessageTimeoutW(window, LB_GETTEXTLEN, selected, 0, SMTO_ABORTIFHUNG, + 200, &length); + if (stringItems && length < std::size(text) && + SendMessageTimeoutW(window, LB_GETTEXT, selected, + reinterpret_cast(text), SMTO_ABORTIFHUNG, + 200, &length)) { + tidy_text(text); + control.value = to_utf8(text); + } + control.position = static_cast(selected) + 1; + } + if (static_cast(count) >= 0) + control.count = static_cast(count); + return control; + } + + if (wcscmp(className, L"msctls_trackbar32") == 0) { + find_control_label(window, label, std::size(label)); + control.name = to_utf8(label); + control.role = "slider"; + DWORD_PTR position = 0; + SendMessageTimeoutW(window, TBM_GETPOS, 0, 0, SMTO_ABORTIFHUNG, 200, + &position); + control.value = std::to_string(static_cast(position)); + return control; + } + + if (wcscmp(className, L"Static") == 0) { + control.name = to_utf8(text); + control.role = "static text"; + return control; + } + + control.name = to_utf8(text); + return control; +} + +bool present_standard_focus(HWND window) { + const ControlDescription control = describe_standard_control(window); + if (control.name.empty() && control.role.empty() && control.value.empty() && + control.description.empty()) + return false; + return present_accessible_event(source_id(window, "standard"), "focus", + control, window_title(window)); +} + void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, LONG objectId, LONG childId, DWORD, DWORD) { if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE && @@ -105,8 +312,11 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, VariantInit(&child); if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible, &child)) || - accessible == nullptr) + accessible == nullptr) { + if (event == EVENT_OBJECT_FOCUS) + present_standard_focus(window); return; + } const std::string name = accessible_property(accessible, &IAccessible::get_accName, child); @@ -131,9 +341,12 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, accessible->Release(); VariantClear(&child); - wchar_t title[512] = {}; - GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512); - const std::string windowTitle = to_utf8(title); + if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() && + description.empty() && (roleText.empty() || roleText == "client") && + present_standard_focus(window)) + return; + + const std::string windowTitle = window_title(window); const std::string sourceId = std::to_string(reinterpret_cast(window)) + ":" + std::to_string(objectId) + ":" + std::to_string(childId); @@ -157,12 +370,19 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, default: return; } - call_boolean("PresentAccessibleEvent", - 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())); + ControlDescription control; + control.name = name; + control.role = roleText; + control.value = value; + control.states = stateText; + control.description = description; + control.count = static_cast(childCount); + present_accessible_event(sourceId, eventType, control, windowTitle); +} + +void reset_rpc_server() { + RpcMgmtStopServerListening(nullptr); + RpcServerUnregisterIf(nullptr, nullptr, FALSE); } bool register_rpc_server() { @@ -173,29 +393,40 @@ bool register_rpc_server() { nullptr) != RPC_S_OK) return false; DWORD sessionId = 0; - if (!ProcessIdToSessionId(GetCurrentProcessId(), &sessionId)) + if (!ProcessIdToSessionId(GetCurrentProcessId(), &sessionId)) { + reset_rpc_server(); return false; + } wchar_t desktopName[256] = {}; DWORD bytesNeeded = 0; if (!GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), UOI_NAME, desktopName, sizeof(desktopName), - &bytesNeeded)) + &bytesNeeded)) { + reset_rpc_server(); return false; + } wchar_t endpoint[512] = {}; wsprintfW(endpoint, L"NvdaCtlr.%lu.%s", sessionId, desktopName); if (RpcServerUseProtseqEpW( reinterpret_cast(const_cast(L"ncalrpc")), - RPC_C_PROTSEQ_MAX_REQS_DEFAULT, - reinterpret_cast(endpoint), - nullptr) != RPC_S_OK) + RPC_C_PROTSEQ_MAX_REQS_DEFAULT, reinterpret_cast(endpoint), + nullptr) != RPC_S_OK) { + reset_rpc_server(); return false; + } for (RPC_IF_HANDLE spec : {NvdaController_v1_0_s_ifspec, NvdaController2_v1_0_s_ifspec, NvdaController3_v1_0_s_ifspec}) { - if (RpcServerRegisterIf(spec, nullptr, nullptr) != RPC_S_OK) + if (RpcServerRegisterIf(spec, nullptr, nullptr) != RPC_S_OK) { + reset_rpc_server(); return false; + } } - return RpcServerListen(1, RPC_C_LISTEN_MAX_CALLS_DEFAULT, TRUE) == RPC_S_OK; + if (RpcServerListen(1, RPC_C_LISTEN_MAX_CALLS_DEFAULT, TRUE) == RPC_S_OK) + return true; + + reset_rpc_server(); + return false; } } // namespace @@ -260,35 +491,51 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { wcsstr(commandLine, L"--no-controller") == nullptr; if (!dialogReaderEnabled && !controllerEnabled) return 0; - if (controllerEnabled && FindWindowW(L"wxWindowClassNR", L"NVDA") != nullptr) + + HANDLE singleton = + CreateMutexW(nullptr, TRUE, L"Local\\cthulhu-wine-access-singleton"); + if (singleton == nullptr) + return 1; + if (GetLastError() == ERROR_ALREADY_EXISTS) { + CloseHandle(singleton); return 0; + } + GError *error = nullptr; connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); if (error != nullptr) g_error_free(error); - if (connection == nullptr || !cthulhu_is_available()) + if (connection == nullptr || !cthulhu_is_available()) { + CloseHandle(singleton); return 1; + } HWND window = nullptr; - if (controllerEnabled) { + bool controllerActive = false; + if (controllerEnabled && + FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) { WNDCLASSW windowClass{}; windowClass.lpfnWndProc = DefWindowProcW; windowClass.hInstance = instance; windowClass.lpszClassName = L"wxWindowClassNR"; RegisterClassW(&windowClass); window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, - windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, - 0, nullptr, nullptr, instance, nullptr); + windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0, + nullptr, nullptr, instance, nullptr); + if (window != nullptr) { + ShowWindow(window, SW_HIDE); + controllerActive = register_rpc_server(); + } } - if ((controllerEnabled && (window == nullptr || !register_rpc_server()))) { - g_object_unref(connection); - return 1; + if (!controllerActive && window != nullptr) { + DestroyWindow(window); + window = nullptr; } + HWINEVENTHOOK hook = nullptr; - if (dialogReaderEnabled) { + if (dialogReaderEnabled) hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); - } MSG message{}; while (GetMessageW(&message, nullptr, 0, 0) > 0) { TranslateMessage(&message); @@ -296,11 +543,12 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { } if (hook != nullptr) UnhookWinEvent(hook); - if (controllerEnabled) { - RpcMgmtStopServerListening(nullptr); - RpcServerUnregisterIf(nullptr, nullptr, FALSE); - DestroyWindow(window); + if (controllerActive) { + reset_rpc_server(); } + if (window != nullptr) + DestroyWindow(window); g_object_unref(connection); + CloseHandle(singleton); return 0; }