Harden Wine standard control support

This commit is contained in:
Storm Dragon
2026-07-17 22:18:25 -04:00
parent f6b7f2ad71
commit 2100fba3ce
3 changed files with 294 additions and 39 deletions
+1 -1
View File
@@ -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"` - **Simple protocol**: `echo "text" | socat - UNIX-CLIENT:"${XDG_RUNTIME_DIR}/cthulhu.sock"`
### Wine And Proton ### 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 - **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` - **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 - **Prefix management**: Runs one helper per active same-user Wine or Proton prefix and preserves Proton's loader
+14 -7
View File
@@ -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 `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` 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 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 restart backoff. If an actual NVDA window already exists in the prefix, the helper yields the
to it. controller endpoint to it while continuing to provide standard-dialog reading.
## Controller Compatibility ## Controller Compatibility
The helper registers the current `NvdaCtlr.<session>.<desktop>` local RPC endpoint, the legacy The helper registers the current `NvdaCtlr.<session>.<desktop>` local RPC endpoint, the legacy
`NvdaCtlr` endpoint used by older official controller clients, and the Controller 1, 2, and 3 `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 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 `nvdaControllerClient32.dll`, `nvdaControllerClient64.dll`, and `Tolk.dll`; no DLL override is
required. required.
@@ -33,10 +34,16 @@ The current Meson target builds the 64-bit helper. A 32-bit helper requires a 32
## Standard Dialog Reading ## Standard Dialog Reading
The first reader stage listens for MSAA focus, name, value, state, and selection events. This makes The first reader stage listens for MSAA focus, name, value, state, and selection events. When Wine's
ordinary Win32 dialogs and controls useful. It does not expose custom-drawn, DirectX, or MSAA proxy has no useful data for a standard control, the helper falls back to the control's Win32
game-specific interfaces. UI Automation coverage and an AT-SPI object broker remain separate later class and message protocol. Combo boxes, list boxes, edits, buttons, sliders, and static controls can
stages; the current helper presents normalized events directly through Cthulhu. 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 ## Prism
+279 -31
View File
@@ -2,6 +2,7 @@
#include "nvdaController.h" #include "nvdaController.h"
#include <commctrl.h>
#include <gio/gio.h> #include <gio/gio.h>
#include <oleacc.h> #include <oleacc.h>
#include <rpc.h> #include <rpc.h>
@@ -10,6 +11,7 @@
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <cwchar> #include <cwchar>
#include <iterator>
#include <string> #include <string>
namespace { namespace {
@@ -19,6 +21,16 @@ constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service";
constexpr int DBUS_TIMEOUT_MS = 500; constexpr int DBUS_TIMEOUT_MS = 500;
GDBusConnection *connection = nullptr; 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) { std::string to_utf8(const wchar_t *text) {
if (text == nullptr) if (text == nullptr)
return {}; return {};
@@ -94,6 +106,201 @@ std::string accessible_property(
return text; 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<LPARAM>(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<std::uintptr_t>(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<DWORD_PTR>(-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<LONG_PTR>(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<LPARAM>(text), SMTO_ABORTIFHUNG,
200, &length)) {
tidy_text(text);
control.value = to_utf8(text);
}
control.position = static_cast<int>(selected) + 1;
}
if (static_cast<LONG_PTR>(count) >= 0)
control.count = static_cast<int>(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<LONG>(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, void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
LONG objectId, LONG childId, DWORD, DWORD) { LONG objectId, LONG childId, DWORD, DWORD) {
if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE && if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE &&
@@ -105,8 +312,11 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
VariantInit(&child); VariantInit(&child);
if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible, if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible,
&child)) || &child)) ||
accessible == nullptr) accessible == nullptr) {
if (event == EVENT_OBJECT_FOCUS)
present_standard_focus(window);
return; return;
}
const std::string name = const std::string name =
accessible_property(accessible, &IAccessible::get_accName, child); accessible_property(accessible, &IAccessible::get_accName, child);
@@ -131,9 +341,12 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
accessible->Release(); accessible->Release();
VariantClear(&child); VariantClear(&child);
wchar_t title[512] = {}; if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() &&
GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512); description.empty() && (roleText.empty() || roleText == "client") &&
const std::string windowTitle = to_utf8(title); present_standard_focus(window))
return;
const std::string windowTitle = window_title(window);
const std::string sourceId = const std::string sourceId =
std::to_string(reinterpret_cast<std::uintptr_t>(window)) + ":" + std::to_string(reinterpret_cast<std::uintptr_t>(window)) + ":" +
std::to_string(objectId) + ":" + std::to_string(childId); std::to_string(objectId) + ":" + std::to_string(childId);
@@ -157,12 +370,19 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
default: default:
return; return;
} }
call_boolean("PresentAccessibleEvent", ControlDescription control;
g_variant_new("(sssssssiis)", sourceId.c_str(), eventType, control.name = name;
name.c_str(), roleText.c_str(), value.c_str(), control.role = roleText;
stateText.c_str(), description.c_str(), 0, control.value = value;
static_cast<int>(childCount), control.states = stateText;
windowTitle.c_str())); control.description = description;
control.count = static_cast<int>(childCount);
present_accessible_event(sourceId, eventType, control, windowTitle);
}
void reset_rpc_server() {
RpcMgmtStopServerListening(nullptr);
RpcServerUnregisterIf(nullptr, nullptr, FALSE);
} }
bool register_rpc_server() { bool register_rpc_server() {
@@ -173,29 +393,40 @@ bool register_rpc_server() {
nullptr) != RPC_S_OK) nullptr) != RPC_S_OK)
return false; return false;
DWORD sessionId = 0; DWORD sessionId = 0;
if (!ProcessIdToSessionId(GetCurrentProcessId(), &sessionId)) if (!ProcessIdToSessionId(GetCurrentProcessId(), &sessionId)) {
reset_rpc_server();
return false; return false;
}
wchar_t desktopName[256] = {}; wchar_t desktopName[256] = {};
DWORD bytesNeeded = 0; DWORD bytesNeeded = 0;
if (!GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), if (!GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()),
UOI_NAME, desktopName, sizeof(desktopName), UOI_NAME, desktopName, sizeof(desktopName),
&bytesNeeded)) &bytesNeeded)) {
reset_rpc_server();
return false; return false;
}
wchar_t endpoint[512] = {}; wchar_t endpoint[512] = {};
wsprintfW(endpoint, L"NvdaCtlr.%lu.%s", sessionId, desktopName); wsprintfW(endpoint, L"NvdaCtlr.%lu.%s", sessionId, desktopName);
if (RpcServerUseProtseqEpW( if (RpcServerUseProtseqEpW(
reinterpret_cast<RPC_WSTR>(const_cast<wchar_t *>(L"ncalrpc")), reinterpret_cast<RPC_WSTR>(const_cast<wchar_t *>(L"ncalrpc")),
RPC_C_PROTSEQ_MAX_REQS_DEFAULT, RPC_C_PROTSEQ_MAX_REQS_DEFAULT, reinterpret_cast<RPC_WSTR>(endpoint),
reinterpret_cast<RPC_WSTR>(endpoint), nullptr) != RPC_S_OK) {
nullptr) != RPC_S_OK) reset_rpc_server();
return false; return false;
}
for (RPC_IF_HANDLE spec : for (RPC_IF_HANDLE spec :
{NvdaController_v1_0_s_ifspec, NvdaController2_v1_0_s_ifspec, {NvdaController_v1_0_s_ifspec, NvdaController2_v1_0_s_ifspec,
NvdaController3_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 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 } // namespace
@@ -260,35 +491,51 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
wcsstr(commandLine, L"--no-controller") == nullptr; wcsstr(commandLine, L"--no-controller") == nullptr;
if (!dialogReaderEnabled && !controllerEnabled) if (!dialogReaderEnabled && !controllerEnabled)
return 0; 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; return 0;
}
GError *error = nullptr; GError *error = nullptr;
connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
if (error != nullptr) if (error != nullptr)
g_error_free(error); g_error_free(error);
if (connection == nullptr || !cthulhu_is_available()) if (connection == nullptr || !cthulhu_is_available()) {
CloseHandle(singleton);
return 1; return 1;
}
HWND window = nullptr; HWND window = nullptr;
if (controllerEnabled) { bool controllerActive = false;
if (controllerEnabled &&
FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) {
WNDCLASSW windowClass{}; WNDCLASSW windowClass{};
windowClass.lpfnWndProc = DefWindowProcW; windowClass.lpfnWndProc = DefWindowProcW;
windowClass.hInstance = instance; windowClass.hInstance = instance;
windowClass.lpszClassName = L"wxWindowClassNR"; windowClass.lpszClassName = L"wxWindowClassNR";
RegisterClassW(&windowClass); RegisterClassW(&windowClass);
window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0,
0, nullptr, nullptr, instance, nullptr); 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; HWINEVENTHOOK hook = nullptr;
if (dialogReaderEnabled) { if (dialogReaderEnabled)
hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0, hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0,
0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
}
MSG message{}; MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0) { while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message); TranslateMessage(&message);
@@ -296,11 +543,12 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
} }
if (hook != nullptr) if (hook != nullptr)
UnhookWinEvent(hook); UnhookWinEvent(hook);
if (controllerEnabled) { if (controllerActive) {
RpcMgmtStopServerListening(nullptr); reset_rpc_server();
RpcServerUnregisterIf(nullptr, nullptr, FALSE);
DestroyWindow(window);
} }
if (window != nullptr)
DestroyWindow(window);
g_object_unref(connection); g_object_unref(connection);
CloseHandle(singleton);
return 0; return 0;
} }