// SPDX-License-Identifier: LGPL-2.1-or-later #include "nvdaController.h" #include "menu_bridge.h" #include "edit_hook.h" #include "msaa_states.h" #include "list_view.h" #include "control_class.h" #include "native_items.h" #include "review_service.h" #include "foreground_focus.h" #include "foreground_hooks.h" #include "reader_endpoint.h" #include "msaa_events.h" #include "reader_events.h" #include "bootstrapper_controls.h" #include "bootstrapper_hook.h" #include "version.h" #include "text_limits.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { // Read directly by readers without starting Wine or initializing a prefix. // Keep this in the sidecar so it identifies the installed executable itself. __attribute__((used, section(".wine_access.version"))) const char installedVersion[] = WINE_ACCESS_VERSION; ReaderEndpoint readerEndpoint = CthulhuEndpoint; constexpr int DBUS_TIMEOUT_MS = 500; GDBusConnection *connection = nullptr; ReviewIdentityStore reviewIdentities; ReviewService reviewService; MsaaEventReader msaaEvents; BootstrapperControlReader bootstrapperReader; HWND lastBootstrapperWindow = nullptr; std::string lastBootstrapperIdentity, lastBootstrapperPresentation; WineAccessBootstrapperState bootstrapperState{}; ReaderEventQueue readerEvents; uint64_t msaaGeneration = 0; MsaaEvent pendingMsaaFocus; // Event-thread owned; retired by focus changes, results or timeout. std::deque menuEvents; std::deque textEvents; std::deque notificationEvents; bool editReaderActive = false; std::string lastMenuEvent; ULONGLONG lastMenuOwner = 0; HWND lastTextControl = nullptr; HWND lastListControl = nullptr; int lastListIndex = -1; int lastListCount = 0; bool lastListSelected = false; int lastListChecked = -1; HWND lastListBoxControl = nullptr; int lastListBoxIndex = -1; int lastListBoxCount = 0; HWND lastNativeControl = nullptr; NativeItemKey lastNativeItem; HWND lastComboControl = nullptr; std::string lastComboValue; bool is_list_box(HWND window) { wchar_t className[128] = {}; return GetClassNameW(window, className, std::size(className)) && (wine_access_class_matches(className, L"ListBox") || wine_access_class_matches(className, L"ComboLBox")); } bool is_combo_box(HWND window) { wchar_t className[128] = {}; return GetClassNameW(window, className, std::size(className)) && (wine_access_class_matches(className, L"ComboBox") || wine_access_class_matches(className, L"ComboBoxEx32")); } HWND combo_event_control(HWND window, DWORD event) { if (is_combo_box(window)) return window; wchar_t className[128] = {}; const bool comboList = GetClassNameW(window, className, std::size(className)) && wine_access_class_matches(className, L"ComboLBox"); if (event != EVENT_OBJECT_FOCUS && !comboList) return nullptr; GUITHREADINFO info{}; info.cbSize = sizeof(info); HWND foreground = GetForegroundWindow(); if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) && is_combo_box(info.hwndFocus)) return info.hwndFocus; return 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 {}; GError *error = nullptr; gchar *converted = g_utf16_to_utf8(reinterpret_cast(text), -1, nullptr, nullptr, &error); if (error != nullptr) g_error_free(error); if (converted == nullptr) return {}; std::string result{converted}; g_free(converted); return result; } DWORD call_boolean(const char *method, GVariant *parameters) { if (connection == nullptr) return ERROR_SERVICE_NOT_ACTIVE; GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( connection, readerEndpoint.busName, readerEndpoint.objectPath, readerEndpoint.interfaceName, method, parameters, G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); if (error != nullptr) g_error_free(error); if (reply == nullptr) return ERROR_GEN_FAILURE; gboolean result = FALSE; g_variant_get(reply, "(b)", &result); g_variant_unref(reply); return result == TRUE ? ERROR_SUCCESS : ERROR_GEN_FAILURE; } bool reader_is_available() { GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", g_variant_new("(s)", readerEndpoint.busName), G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); if (error != nullptr) g_error_free(error); if (reply == nullptr) return false; gboolean owned = FALSE; g_variant_get(reply, "(b)", &owned); g_variant_unref(reply); return owned == TRUE; } bool reader_supports_protocol(bool controllerEnabled, bool dialogReaderEnabled) { GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( connection, readerEndpoint.busName, readerEndpoint.objectPath, "org.freedesktop.DBus.Introspectable", "Introspect", nullptr, G_VARIANT_TYPE("(s)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); if (error) g_error_free(error); if (!reply) { std::fprintf(stderr, "wine-access: reader object %s is unavailable\n", readerEndpoint.objectPath); return false; } const char *xml = nullptr; g_variant_get(reply, "(&s)", &xml); GDBusNodeInfo *node = g_dbus_node_info_new_for_xml(xml, nullptr); g_variant_unref(reply); GDBusInterfaceInfo *interfaceInfo = node ? g_dbus_node_info_lookup_interface(node, readerEndpoint.interfaceName) : nullptr; struct RequiredMethod { const char *name; const char *inputs; const char *outputs; bool required; }; const RequiredMethod methods[] = { // UIA notifications use speech directly even without the RPC controller. {"SpeakText", "sb", "b", controllerEnabled || dialogReaderEnabled}, {"CancelSpeech", "", "b", controllerEnabled || dialogReaderEnabled}, {"BrailleMessage", "s", "b", controllerEnabled}, {"SpeakSsml", "ss", "i", controllerEnabled}, {"PresentAccessibleEvent", "sssssssiis", "b", dialogReaderEnabled}}; bool supported = interfaceInfo != nullptr; if (!interfaceInfo) std::fprintf(stderr, "wine-access: reader interface %s is unavailable\n", readerEndpoint.interfaceName); for (const auto &required : methods) { if (!interfaceInfo || !required.required) continue; GDBusMethodInfo *method = g_dbus_interface_info_lookup_method(interfaceInfo, required.name); std::string inputs, outputs; if (method) { for (auto args = method->in_args; args && *args; ++args) inputs += (*args)->signature; for (auto args = method->out_args; args && *args; ++args) outputs += (*args)->signature; } if (!method || inputs != required.inputs || outputs != required.outputs) { std::fprintf(stderr, "wine-access: reader method %s requires %s -> %s\n", required.name, required.inputs, required.outputs); supported = false; } } if (node) g_dbus_node_info_unref(node); return supported; } // Standard-control queries follow the LGPL wine-a11y reader published by // Mudb0y, while presentation uses the selected reader's D-Bus interface. void strip_mnemonics(wchar_t *text) { wchar_t *output = text; for (wchar_t *input = text; *input != L'\0'; ++input) { if (*input == L'&') { if (input[1] != L'&') continue; ++input; } *output++ = *input; } *output = L'\0'; } void trim_label(wchar_t *text) { const int length = lstrlenW(text); if (length && text[length - 1] == L':') text[length - 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; // Only captions interpret mnemonic markers. Values and window titles // contain literal text; even captions escape a literal ampersand as &&. wchar_t className[128] = {}; if (GetClassNameW(window, className, std::size(className)) && (wine_access_class_matches(className, L"Button") || (wine_access_class_matches(className, L"Static") && !(GetWindowLongPtrW(window, GWL_STYLE) & SS_NOPREFIX)))) strip_mnemonics(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'; const bool knownClass = GetClassNameW(window, className, std::size(className)); const bool managedEdit = knownClass && lstrcmpiW(className, L"Edit") != 0 && wine_access_class_matches(className, L"Edit"); // Controls need not be created in label/control order. Prefer a // visible static label immediately to the left on the same row, among // siblings only. Keep the existing native-control ordering below. if (knownClass && (wine_access_class_matches(className, L"Edit") || wine_access_class_matches(className, L"ComboBox") || wine_access_class_matches(className, L"ComboBoxEx32"))) { RECT controlRect{}; if (GetWindowRect(window, &controlRect)) { HWND label = nullptr; LONG bestGap = 201; HWND sibling = GetWindow(GetParent(window), GW_CHILD); for (int hop = 0; sibling && hop < 128; ++hop, sibling = GetWindow(sibling, GW_HWNDNEXT)) { RECT labelRect{}; if (sibling == window || !IsWindowVisible(sibling) || !GetClassNameW(sibling, className, std::size(className)) || !wine_access_class_matches(className, L"Static") || !GetWindowRect(sibling, &labelRect)) continue; const LONG gap = controlRect.left - labelRect.right; if (gap >= 0 && gap < bestGap && labelRect.top < controlRect.bottom && labelRect.bottom > controlRect.top) { label = sibling; bestGap = gap; } } if (label && get_control_text(label, text, capacity)) { trim_label(text); return; } } // Retain ordered labels for controls without a same-row sibling. if (managedEdit) return; } for (int hop = 0; hop < 3; ++hop) { previous = GetWindow(previous, GW_HWNDPREV); if (previous == nullptr || !GetClassNameW(previous, className, std::size(className))) return; if (!wine_access_class_matches(className, L"Static") && !wine_access_class_matches(className, L"Button")) continue; if (get_control_text(previous, text, capacity)) trim_label(text); return; } } std::string window_title(HWND window) { wchar_t title[512] = {}; GetWindowTextW(GetAncestor(window, GA_ROOT), title, std::size(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) { // Readers can update their focus owner even when they suppress its output. if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text")) lastTextControl = nullptr; return readerEvents.submit(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())); } 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; const LONG_PTR windowStyle = GetWindowLongPtrW(window, GWL_STYLE); const bool isEdit = wine_access_class_matches(className, L"Edit") || wine_access_class_matches(className, L"RICHEDIT50W") || wine_access_class_matches(className, L"RichEdit20W") || wine_access_class_matches(className, L"RichEdit20A"); if (!isEdit || !(windowStyle & ES_PASSWORD)) get_control_text(window, text, std::size(text)); if (wine_access_class_matches(className, L"Button")) { 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; } 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 (wine_access_class_matches(className, L"ComboBox") || wine_access_class_matches(className, L"ComboBoxEx32")) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "combo box"; control.value = to_utf8(text); lastComboControl = window; lastComboValue = control.value; return control; } if (is_list_view(window)) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "list"; const auto row = read_list_view(window); control.value = row.text; if (!row.text.empty()) control.position = row.index + 1; control.count = row.count; if (row.selected) control.states = "selected"; if (row.checked >= 0) { if (!control.states.empty()) control.states += ", "; control.states += row.checked ? "checked" : "not checked"; } if (row.available) { lastListControl = window; lastListIndex = row.index; lastListCount = row.count; lastListSelected = row.selected; lastListChecked = row.checked; } else lastListControl = nullptr; // Retry after a late native reply. return control; } if (is_native_item_control(window)) { const auto item = read_native_item(window); control.name = item.text; control.role = item.role; control.states = item.states; control.position = item.position; control.count = item.count; if (item.available) { lastNativeControl = window; lastNativeItem = item.key; } else lastNativeControl = nullptr; return control; } if (wine_access_class_matches(className, L"ListBox") || wine_access_class_matches(className, L"ComboLBox")) { 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)) { control.value = to_utf8(text); } control.position = static_cast(selected) + 1; } if (static_cast(count) >= 0) control.count = static_cast(count); lastListBoxControl = window; lastListBoxIndex = static_cast(selected); lastListBoxCount = control.count; return control; } if (wine_access_class_matches(className, L"msctls_trackbar32")) { 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 (wine_access_class_matches(className, L"Static")) { control.name = to_utf8(text); control.role = "static text"; return control; } control.name = to_utf8(text); return control; } bool present_standard_focus(HWND window) { // Read through the subclass so returning from a menu reports the caret's // line, not the beginning of the document. Never block on the callback. if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY)) return PostMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); 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 poll_control_focus(bool refresh = false) { GUITHREADINFO info{}; info.cbSize = sizeof(info); HWND foreground = GetForegroundWindow(); const bool focused = foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) && !(info.flags & GUI_INMENUMODE); HWND bootstrapper = focused && info.hwndFocus == foreground && is_bootstrapper_window(foreground) ? foreground : nullptr; bootstrapperReader.observe(bootstrapper); if (bootstrapper != lastBootstrapperWindow) { bootstrapperState = {}; lastBootstrapperWindow = bootstrapper; lastBootstrapperIdentity.clear(); lastBootstrapperPresentation.clear(); } // Keep one native query outstanding even if the application stops pumping // messages. The input hook clears the marker after the query completes. HWND bridge = bootstrapper ? FindWindowW(WINE_ACCESS_MENU_CLASS, nullptr) : nullptr; if (bootstrapper && bridge && GetPropW(bootstrapper, BOOTSTRAPPER_STATE_READY) == reinterpret_cast(bridge) && !GetPropW(bootstrapper, BOOTSTRAPPER_STATE_PENDING) && SetPropW(bootstrapper, BOOTSTRAPPER_STATE_PENDING, reinterpret_cast(1))) { if (!PostMessageW(bootstrapper, RegisterWindowMessageW(BOOTSTRAPPER_STATE_MESSAGE), 0, 0)) RemovePropW(bootstrapper, BOOTSTRAPPER_STATE_PENDING); } BootstrapperControl result; if (bootstrapperReader.take(result) && result.available && result.window == bootstrapper && GetTickCount64() < result.deadline) { DWORD process = 0; const DWORD thread = GetWindowThreadProcessId(bootstrapper, &process); if (thread == result.thread && process == result.process) { ControlDescription control; control.name = result.name; control.role = result.role; control.value = result.value; control.states = result.states; if (control.role == "check box" && bootstrapperState.owner == static_cast(reinterpret_cast(bootstrapper)) && GetTickCount64() - bootstrapperState.tick < 500 && to_utf8(bootstrapperState.name) == control.name) { control.states = bootstrapperState.state == 0 ? "not checked" : bootstrapperState.state == 1 ? "checked" : "half checked"; if (bootstrapperState.flags & WINE_ACCESS_BOOTSTRAPPER_PASSWORD) control.states += ", password"; if (bootstrapperState.flags & WINE_ACCESS_BOOTSTRAPPER_DISABLED) control.states += ", unavailable"; } // Deduplicate the actual presentation, not superseded UIA state flags. const std::string presentation = result.identity + "\n" + control.name + "\n" + control.role + "\n" + control.value + "\n" + control.states; const bool focusChanged = result.identity != lastBootstrapperIdentity; if (presentation != lastBootstrapperPresentation && present_accessible_event(source_id(bootstrapper, "bootstrapper-uia") + result.identity, focusChanged ? "focus" : control.role == "check box" ? "state" : "value", control, window_title(bootstrapper))) { lastBootstrapperIdentity = result.identity; lastBootstrapperPresentation = presentation; } } } const bool focusedListBox = focused && is_list_box(info.hwndFocus); if (!focusedListBox) { lastListBoxControl = nullptr; lastListBoxIndex = -1; lastListBoxCount = 0; } if (!focused || !is_combo_box(info.hwndFocus)) { lastComboControl = nullptr; lastComboValue.clear(); } if (focused && is_native_item_control(info.hwndFocus)) { lastListControl = nullptr; lastListIndex = -1; lastListCount = 0; lastListSelected = false; lastListChecked = -1; const auto key = native_item_key(info.hwndFocus); if (info.hwndFocus != lastNativeControl) { present_standard_focus(info.hwndFocus); } else if (refresh || !(key == lastNativeItem)) { const auto control = describe_standard_control(info.hwndFocus); present_accessible_event(source_id(info.hwndFocus, "standard"), "selection", control, window_title(info.hwndFocus)); } return; } lastNativeControl = nullptr; lastNativeItem = {}; if (focused && is_list_view(info.hwndFocus)) { int count = 0; const int index = list_view_index(info.hwndFocus, &count); DWORD_PTR state = 0; if (index >= 0) SendMessageTimeoutW(info.hwndFocus, LVM_GETITEMSTATE, index, LVIS_SELECTED, SMTO_ABORTIFHUNG, 100, &state); const bool selected = state & LVIS_SELECTED; const int checked = list_view_checked(info.hwndFocus, index); if (info.hwndFocus != lastListControl) { present_standard_focus(info.hwndFocus); } else if (refresh || index != lastListIndex || count != lastListCount || selected != lastListSelected || checked != lastListChecked) { const auto control = describe_standard_control(info.hwndFocus); present_accessible_event(source_id(info.hwndFocus, "standard"), "selection", control, window_title(info.hwndFocus)); } return; } lastListControl = nullptr; lastListIndex = -1; lastListCount = 0; lastListSelected = false; lastListChecked = -1; if (focusedListBox) { DWORD_PTR selected = static_cast(-1); DWORD_PTR count = 0; if (!SendMessageTimeoutW(info.hwndFocus, LB_GETCURSEL, 0, 0, SMTO_ABORTIFHUNG, 100, &selected) || !SendMessageTimeoutW(info.hwndFocus, LB_GETCOUNT, 0, 0, SMTO_ABORTIFHUNG, 100, &count) || static_cast(count) < 0) return; if (info.hwndFocus != lastListBoxControl) { present_standard_focus(info.hwndFocus); } else if (refresh || static_cast(selected) != lastListBoxIndex || static_cast(count) != lastListBoxCount) { const auto control = describe_standard_control(info.hwndFocus); present_accessible_event(source_id(info.hwndFocus, "standard"), "selection", control, window_title(info.hwndFocus)); } return; } if (focused && is_combo_box(info.hwndFocus)) { if (info.hwndFocus != lastComboControl) { present_standard_focus(info.hwndFocus); } else { wchar_t text[512] = {}; get_control_text(info.hwndFocus, text, std::size(text)); if (refresh || to_utf8(text) != lastComboValue) { const auto control = describe_standard_control(info.hwndFocus); present_accessible_event(source_id(info.hwndFocus, "standard"), "value", control, window_title(info.hwndFocus)); } } } } void present_menu_event(const WineAccessMenuEvent &event) { HWND owner = reinterpret_cast(static_cast(event.owner)); if (owner == nullptr) return; if (event.flags & WINE_ACCESS_MENU_CLOSED) { if (!lastMenuEvent.empty() && event.owner == lastMenuOwner) { lastMenuEvent.clear(); lastMenuOwner = 0; GUITHREADINFO info{}; info.cbSize = sizeof(info); if (owner == GetAncestor(GetForegroundWindow(), GA_ROOT) && GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) && info.hwndFocus != nullptr) present_standard_focus(info.hwndFocus); } return; } if (owner != GetAncestor(GetForegroundWindow(), GA_ROOT)) return; ControlDescription control; control.name = to_utf8(event.name); control.role = event.flags & WINE_ACCESS_MENU_SUBMENU ? "submenu" : "menu item"; control.value = to_utf8(event.shortcut); if (event.flags & WINE_ACCESS_MENU_CHECKED) control.description = "checked"; if (event.flags & WINE_ACCESS_MENU_DISABLED) { if (!control.description.empty()) control.description += ", "; control.description += "unavailable"; } const std::string id = std::to_string(event.owner) + ":menu:" + std::to_string(event.menu) + ":" + std::to_string(event.item); const std::string key = id + "\n" + control.name + "\n" + control.value + "\n" + std::to_string(event.flags); if (key == lastMenuEvent) return; if (present_accessible_event(id, "focus", control, to_utf8(event.title))) { lastMenuEvent = key; lastMenuOwner = event.owner; } } void present_text_event(const WineAccessTextEvent &event) { HWND owner = reinterpret_cast(static_cast(event.owner)); HWND window = reinterpret_cast(static_cast(event.control)); GUITHREADINFO info{}; info.cbSize = sizeof(info); if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || !GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window) return; const bool scintilla = event.flags & WINE_ACCESS_TEXT_SCINTILLA; const bool readOnly = scintilla ? (event.flags & WINE_ACCESS_TEXT_READONLY) != 0 : (GetWindowLongPtrW(window, GWL_STYLE) & ES_READONLY) != 0; if (!scintilla && (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) && event.kind != WINE_ACCESS_TEXT_PASSWORD) return; std::string text = to_utf8(event.text); ControlDescription control; if (event.kind == WINE_ACCESS_TEXT_PASSWORD) { wchar_t label[256] = {}; find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "edit"; control.description = "password"; } else if (event.kind == WINE_ACCESS_TEXT_FOCUS) { wchar_t label[256] = {}; find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = readOnly ? "read only edit" : "edit"; control.value = text.empty() ? "blank" : text; } else { if (text == " ") text = "space"; else if (text == "\t") text = "tab"; else if (text == "\r" || text == "\n" || text == "\r\n") text = "new line"; const char *format = nullptr; if (event.kind == WINE_ACCESS_TEXT_SELECTED) format = "%s selected"; else if (event.kind == WINE_ACCESS_TEXT_UNSELECTED) format = "%s unselected"; else if (event.kind == WINE_ACCESS_TEXT_DELETED) format = "%s deleted"; if (format != nullptr) { gchar *message = g_strdup_printf(format, text.c_str()); control.name = message; g_free(message); } else { control.name = text.empty() ? "blank" : text; } } const bool focus = event.kind == WINE_ACCESS_TEXT_FOCUS || event.kind == WINE_ACCESS_TEXT_PASSWORD; const auto source = source_id(window, "text"); const auto title = to_utf8(event.title); // A reader restart can encounter an editor whose subclass already exists. // Re-establish focus before its first action without re-reading the document. if (!focus && lastTextControl != window) { ControlDescription focused; focused.role = readOnly ? "read only edit" : "edit"; if (!present_accessible_event(source, "focus", focused, title)) return; } if (event.kind != WINE_ACCESS_TEXT_PASSWORD) control.states = "start=" + std::to_string(event.start) + " end=" + std::to_string(event.end) + " kind=" + std::to_string(event.kind); if (scintilla) control.states += " offset_unit=byte"; // Text commands are ordered actions, not transient focus notifications. if (present_accessible_event(source, focus ? "focus" : "text", control, title)) lastTextControl = window; } LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { if (message == WM_COPYDATA && lp != 0) { const auto *data = reinterpret_cast(lp); if (data->dwData == WINE_ACCESS_BOOTSTRAPPER_TAG && data->cbData == sizeof(WineAccessBootstrapperState) && data->lpData) { const auto event = *static_cast(data->lpData); HWND owner = reinterpret_cast(static_cast(event.owner)); if (owner != GetForegroundWindow() || !is_bootstrapper_window(owner) || event.state > 2 || GetTickCount64() - event.tick >= 500) return FALSE; bootstrapperState = event; bootstrapperState.name[std::size(bootstrapperState.name) - 1] = 0; return TRUE; } if (data->dwData == WINE_ACCESS_NOTIFICATION_TAG && data->cbData == sizeof(WineAccessNotificationEvent) && data->lpData != nullptr && notificationEvents.size() < 32) { auto event = *static_cast(data->lpData); event.text[std::size(event.text) - 1] = L'\0'; notificationEvents.push_back(event); if (!PostMessageW(window, WINE_ACCESS_MENU_MESSAGE + 2, 0, 0)) { notificationEvents.pop_back(); return FALSE; } return TRUE; } if (data->dwData == WINE_ACCESS_TEXT_TAG && data->cbData == sizeof(WineAccessTextEvent) && data->lpData != nullptr && textEvents.size() < 32) { auto event = *static_cast(data->lpData); event.text[std::size(event.text) - 1] = L'\0'; event.title[std::size(event.title) - 1] = L'\0'; textEvents.push_back(event); if (!PostMessageW(window, WINE_ACCESS_MENU_MESSAGE + 1, 0, 0)) { textEvents.pop_back(); return FALSE; } return TRUE; } if (data->dwData != WINE_ACCESS_MENU_TAG || data->cbData != sizeof(WineAccessMenuEvent) || data->lpData == nullptr || menuEvents.size() >= 32) return FALSE; auto event = *static_cast(data->lpData); event.name[std::size(event.name) - 1] = L'\0'; event.shortcut[std::size(event.shortcut) - 1] = L'\0'; event.title[std::size(event.title) - 1] = L'\0'; menuEvents.push_back(event); if (!PostMessageW(window, WINE_ACCESS_MENU_MESSAGE, 0, 0)) { menuEvents.pop_back(); return FALSE; } return TRUE; } if (message == WINE_ACCESS_MENU_MESSAGE) { if (!menuEvents.empty()) { const auto event = menuEvents.front(); menuEvents.pop_front(); present_menu_event(event); } return 0; } if (message == WINE_ACCESS_MENU_MESSAGE + 1) { if (!textEvents.empty()) { const auto event = textEvents.front(); textEvents.pop_front(); present_text_event(event); } return 0; } if (message == WINE_ACCESS_MENU_MESSAGE + 2) { if (!notificationEvents.empty()) { const auto event = notificationEvents.front(); notificationEvents.pop_front(); DWORD foregroundProcess = 0; GetWindowThreadProcessId(GetForegroundWindow(), &foregroundProcess); if (event.process == foregroundProcess) { const bool interrupt = event.processing == NotificationProcessing_ImportantAll || event.processing == NotificationProcessing_ImportantMostRecent; if (event.text[0]) call_boolean("SpeakText", g_variant_new("(sb)", to_utf8(event.text).c_str(), interrupt)); else if (interrupt) call_boolean("CancelSpeech", g_variant_new("()")); } } return 0; } return DefWindowProcW(window, message, wp, lp); } bool helper_file_path(wchar_t *path, size_t capacity, const wchar_t *name) { const DWORD length = GetModuleFileNameW(nullptr, path, capacity); if (length == 0 || length >= capacity) return false; wchar_t *separator = nullptr; for (wchar_t *cursor = path; *cursor; ++cursor) if (*cursor == L'\\' || *cursor == L'/') separator = cursor; if (separator == nullptr) return false; const auto remaining = capacity - (separator + 1 - path); if (remaining <= static_cast(lstrlenW(name))) return false; lstrcpyW(separator + 1, name); return true; } HMODULE load_menu_hook() { wchar_t path[4096] = {}; return helper_file_path(path, std::size(path), L"wine-access-menu.dll") ? LoadLibraryW(path) : nullptr; } HANDLE start_menu_host32(HWND bridge) { wchar_t path[4096] = {}; if (!helper_file_path(path, std::size(path), L"wine-access-menu-host32.exe")) return nullptr; wchar_t command[8192] = {}; wsprintfW(command, L"\"%s\" %lu %lu", path, GetCurrentProcessId(), static_cast(reinterpret_cast(bridge))); STARTUPINFOW startup{}; startup.cb = sizeof(startup); PROCESS_INFORMATION process{}; if (!CreateProcessW(path, command, nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup, &process)) return nullptr; CloseHandle(process.hThread); return process.hProcess; } void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, LONG objectId, LONG childId, DWORD, DWORD) { const bool presentationEvent = event == EVENT_OBJECT_FOCUS || event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE || event == EVENT_OBJECT_SELECTION || event == EVENT_OBJECT_STATECHANGE; if (presentationEvent && !msaa_event_in_foreground(window)) return; if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND) { bootstrapperReader.observe(nullptr); bootstrapperState = {}; lastBootstrapperWindow = nullptr; lastBootstrapperIdentity.clear(); lastBootstrapperPresentation.clear(); pendingMsaaFocus = {}; ++msaaGeneration; msaaEvents.invalidate(msaaGeneration); } if (event == EVENT_OBJECT_DESTROY) reviewIdentities.destroyed(window,objectId,childId); else if (event == EVENT_OBJECT_REORDER) reviewIdentities.reordered(window,objectId); if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND) reviewService.focus_changed(reviewIdentities.observe_foreground()); if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE && event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION && event != EVENT_OBJECT_STATECHANGE) return; if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY)) return; if (HWND combo = combo_event_control(window, event)) { // The combo's internal list can emit focus events even while the combo // retains keyboard focus. Keep a single source for focus and value updates. GUITHREADINFO info{}; info.cbSize = sizeof(info); HWND foreground = GetForegroundWindow(); if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) && !(info.flags & GUI_INMENUMODE) && info.hwndFocus == combo) { if (event == EVENT_OBJECT_FOCUS && window == combo) present_standard_focus(combo); else poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE); } return; } // List boxes can omit selection WinEvents just like list views. Route all // their events through the foreground poll, keeping one source and avoiding // announcements of background selection changes. if (is_list_box(window)) { poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE); return; } // Wine's MSAA provider exposes native list views as named clients, without // their rows. Use the standard-control query even when that name is present. if (is_list_view(window) || is_native_item_control(window)) { if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window); else poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE); return; } DWORD process = 0; DWORD thread = GetWindowThreadProcessId(window, &process); MsaaEvent request{window, event, process, thread, objectId, childId, msaaGeneration, GetTickCount64() + 500}; if (event == EVENT_OBJECT_FOCUS) pendingMsaaFocus = request; msaaEvents.submit(request); } bool current_msaa_source(const MsaaEvent &source) { DWORD process = 0; DWORD thread = GetWindowThreadProcessId(source.window, &process); return source.generation == msaaGeneration && IsWindow(source.window) && process == source.process && thread == source.thread && msaa_event_in_foreground(source.window); } void present_msaa_result(const MsaaResult &result) { const auto &source = result.source; if (!current_msaa_source(source) || GetTickCount64() >= source.deadline) return; if (source.event == EVENT_OBJECT_FOCUS && pendingMsaaFocus.generation == source.generation) pendingMsaaFocus = {}; HWND window = source.window; DWORD event = source.event; LONG objectId = source.object, childId = source.child; if (!result.available) { if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window); return; } const auto &name = result.name, &value = result.value, &description = result.description; const auto &roleText = result.role, &stateText = result.states; bool password = result.password, nativeClient = result.native_client; LONG childCount = result.count; 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); 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; } ControlDescription control; control.name = name; control.role = roleText; control.value = password ? "" : value; control.states = stateText; control.description = description; control.count = static_cast(childCount); // Wine can return named generic clients even for standard buttons and // combo boxes. Their names alone must not hide the native role/value. // Application-owned list items and other meaningful MSAA roles stay intact. if (nativeClient) { const auto native = describe_standard_control(window); if (!native.role.empty()) { control = native; if (control.name.empty()) control.name = name; if (control.states.empty()) control.states = stateText; if (control.description.empty()) control.description = description; if (password) control.value.clear(); } } present_accessible_event(sourceId, eventType, control, windowTitle); } void reset_rpc_server() { RpcMgmtStopServerListening(nullptr); RpcServerUnregisterIf(nullptr, nullptr, FALSE); } bool register_rpc_server() { if (RpcServerUseProtseqEpW( reinterpret_cast(const_cast(L"ncalrpc")), RPC_C_PROTSEQ_MAX_REQS_DEFAULT, reinterpret_cast(const_cast(L"NvdaCtlr")), nullptr) != RPC_S_OK) return false; DWORD sessionId = 0; 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)) { 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) { 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) { reset_rpc_server(); return false; } } if (RpcServerListen(1, RPC_C_LISTEN_MAX_CALLS_DEFAULT, TRUE) == RPC_S_OK) return true; reset_rpc_server(); return false; } } // namespace extern "C" void *__RPC_USER MIDL_user_allocate(size_t size) { return malloc(size); } extern "C" void __RPC_USER MIDL_user_free(void *pointer) { free(pointer); } extern "C" error_status_t __stdcall testIfRunning(void) { return reader_is_available() ? ERROR_SUCCESS : ERROR_SERVICE_NOT_ACTIVE; } extern "C" error_status_t __stdcall speakText(const wchar_t *text) { if (!controller_text_fits(text)) return ERROR_INVALID_PARAMETER; const std::string utf8 = to_utf8(text); // Tolk calls cancelSpeech separately when its interrupt argument is true. return call_boolean("SpeakText", g_variant_new("(sb)", utf8.c_str(), FALSE)); } extern "C" error_status_t __stdcall cancelSpeech(void) { return call_boolean("CancelSpeech", nullptr); } extern "C" error_status_t __stdcall brailleMessage(const wchar_t *text) { if (!controller_text_fits(text)) return ERROR_INVALID_PARAMETER; const std::string utf8 = to_utf8(text); return call_boolean("BrailleMessage", g_variant_new("(s)", utf8.c_str())); } extern "C" error_status_t __stdcall getProcessId(ULONG *pid) { if (pid == nullptr) return ERROR_INVALID_PARAMETER; *pid = GetCurrentProcessId(); return ERROR_SUCCESS; } extern "C" error_status_t __stdcall speakSsml(const wchar_t *ssml, SYMBOL_LEVEL, SPEECH_PRIORITY, boolean) { if (!controller_text_fits(ssml)) return ERROR_INVALID_PARAMETER; const std::string utf8 = to_utf8(ssml); GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( connection, readerEndpoint.busName, readerEndpoint.objectPath, readerEndpoint.interfaceName, "SpeakSsml", g_variant_new("(ss)", "wine-nvda-controller", utf8.c_str()), G_VARIANT_TYPE("(i)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); if (error != nullptr) g_error_free(error); if (reply == nullptr) return ERROR_GEN_FAILURE; gint result = ERROR_GEN_FAILURE; g_variant_get(reply, "(i)", &result); g_variant_unref(reply); return static_cast(result); } extern "C" error_status_t __stdcall onSsmlMarkReached(const wchar_t *) { return ERROR_CALL_NOT_IMPLEMENTED; } extern "C" error_status_t __stdcall isSpeaking(boolean *speaking) { if (speaking == nullptr) return ERROR_INVALID_PARAMETER; *speaking = FALSE; return ERROR_CALL_NOT_IMPLEMENTED; } int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { const wchar_t *commandLine = GetCommandLineW(); int argumentCount = 0; wchar_t **arguments = CommandLineToArgvW(commandLine, &argumentCount); if (arguments == nullptr) return 1; // Version queries must work without a reader, even while another helper owns // the singleton. The identity is embedded in this executable's sidecar. for (int index = 1; index < argumentCount; ++index) { if (lstrcmpW(arguments[index], L"--version") == 0) { std::puts(WINE_ACCESS_BUILD); LocalFree(arguments); return 0; } } std::fprintf(stderr, "wine-access: identity {\"version\":\"%s\",\"revision\":\"%s\",\"modified\":%s}\n", WINE_ACCESS_VERSION, WINE_ACCESS_REVISION, WINE_ACCESS_MODIFIED); bool validReader = true; bool validEndpoint = true; std::string readerPath, readerInterface; bool dialogReaderEnabled = true, controllerEnabled = true; for (int index = 1; index < argumentCount; ++index) { if (lstrcmpW(arguments[index], L"--no-dialog-reader") == 0) { dialogReaderEnabled = false; continue; } if (lstrcmpW(arguments[index], L"--no-controller") == 0) { controllerEnabled = false; continue; } const bool pathOption = lstrcmpW(arguments[index], L"--reader-path") == 0; const bool interfaceOption = lstrcmpW(arguments[index], L"--reader-interface") == 0; if (pathOption || interfaceOption) { if (++index == argumentCount) { validEndpoint = false; break; } std::string value = to_utf8(arguments[index]); if (pathOption ? !g_variant_is_object_path(value.c_str()) : !g_dbus_is_interface_name(value.c_str())) { validEndpoint = false; break; } if (pathOption) readerPath = value; else readerInterface = value; continue; } if (lstrcmpW(arguments[index], L"--reader") != 0) continue; if (++index == argumentCount) { validReader = false; break; } if (lstrcmpW(arguments[index], L"cthulhu") == 0) readerEndpoint = CthulhuEndpoint; else if (lstrcmpW(arguments[index], L"orca") == 0) readerEndpoint = OrcaEndpoint; else { validReader = false; break; } } LocalFree(arguments); if (!validReader) { std::fprintf(stderr, "wine-access: --reader requires cthulhu or orca\n"); return 1; } if (!validEndpoint) { std::fprintf(stderr, "wine-access: --reader-path and --reader-interface require valid D-Bus values\n"); return 1; } // Apply overrides after reader selection, independent of argument order. // These strings remain alive until the workers have stopped at shutdown. if (!readerPath.empty()) readerEndpoint.objectPath = readerPath.c_str(); if (!readerInterface.empty()) readerEndpoint.interfaceName = readerInterface.c_str(); if (!dialogReaderEnabled && !controllerEnabled) { std::fprintf(stderr, "wine-access: disabled by command-line options\n"); return 0; } HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\cthulhu-wine-access-singleton"); if (singleton == nullptr) { std::fprintf(stderr, "wine-access: singleton creation failed (error %lu)\n", static_cast(GetLastError())); return 1; } if (GetLastError() == ERROR_ALREADY_EXISTS) { std::fprintf(stderr, "wine-access: another helper already owns this Wine desktop\n"); 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 || !reader_is_available()) { std::fprintf(stderr, "wine-access: reader service %s is unavailable\n", readerEndpoint.busName); if (connection != nullptr) g_object_unref(connection); CloseHandle(singleton); return 1; } if (!reader_supports_protocol(controllerEnabled, dialogReaderEnabled)) { g_object_unref(connection); CloseHandle(singleton); return 1; } // Application-owned MSAA objects (including Windows Forms menus) are // marshaled across processes. Initialize their callback/message-loop thread; // Wine's built-in control proxies can otherwise hide this missing setup. const HRESULT comResult = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(comResult)) { std::fprintf(stderr, "wine-access: COM initialization failed (error %lx)\n", static_cast(comResult)); g_object_unref(connection); CloseHandle(singleton); return 1; } HWND window = nullptr; if (dialogReaderEnabled && !readerEvents.start(connection, readerEndpoint)) { std::fprintf(stderr, "wine-access: reader event worker unavailable\n"); CoUninitialize(); g_object_unref(connection); CloseHandle(singleton); return 1; } const bool reviewActive = reviewService.start(connection,reviewIdentities,readerEndpoint); if (!reviewActive) std::fprintf(stderr, "wine-access: review service unavailable\n"); const bool msaaActive = dialogReaderEnabled && msaaEvents.start(); if (dialogReaderEnabled && !msaaActive) { std::fprintf(stderr, "wine-access: MSAA event worker unavailable\n"); } 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); if (window != nullptr) { ShowWindow(window, SW_HIDE); controllerActive = register_rpc_server(); } } if (!controllerActive && window != nullptr) { DestroyWindow(window); window = nullptr; } if (controllerEnabled && !controllerActive) std::fprintf(stderr, "wine-access: controller endpoint unavailable\n"); HWINEVENTHOOK hook = nullptr; WineAccessForegroundHooks foregroundHooks{}; HMODULE menuModule = nullptr; HWND menuBridge = nullptr; HANDLE menuHost32 = nullptr; DWORD menuHookError = 0, inputHookError = 0; if (dialogReaderEnabled) { hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); if (hook == nullptr) std::fprintf(stderr, "wine-access: WinEvent hook unavailable (error %lu)\n", static_cast(GetLastError())); WNDCLASSW bridgeClass{}; bridgeClass.lpfnWndProc = menu_bridge_proc; bridgeClass.hInstance = instance; bridgeClass.lpszClassName = WINE_ACCESS_MENU_CLASS; RegisterClassW(&bridgeClass); menuBridge = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, WINE_ACCESS_MENU_CLASS, L"", 0, 0, 0, 0, 0, nullptr, nullptr, instance, nullptr); if (menuBridge == nullptr) { menuHookError = inputHookError = GetLastError(); } else if ((menuModule = load_menu_hook()) == nullptr) { menuHookError = inputHookError = GetLastError(); } else { foregroundHooks.module = menuModule; foregroundHooks.menu_callback = std::bit_cast(GetProcAddress(menuModule, "WineAccessMenuHook")); if (!foregroundHooks.menu_callback) menuHookError = GetLastError(); foregroundHooks.input_callback = std::bit_cast(GetProcAddress(menuModule, "WineAccessInputHook")); if (!foregroundHooks.input_callback) inputHookError = GetLastError(); editReaderActive = foregroundHooks.menu_callback && foregroundHooks.input_callback; wine_access_update_hooks(&foregroundHooks, FALSE); } if (menuHookError) std::fprintf(stderr, "wine-access: native menu hook unavailable (error %lu)\n", static_cast(menuHookError)); if (inputHookError) std::fprintf(stderr, "wine-access: native input hook unavailable (error %lu)\n", static_cast(inputHookError)); if (menuBridge != nullptr) { menuHost32 = start_menu_host32(menuBridge); if (menuHost32 == nullptr) std::fprintf(stderr, "wine-access: 32-bit menu host unavailable (error %lu)\n", static_cast(GetLastError())); // Existing subclasses keep working after foreground hooks move away, // including those attached by the 32-bit host. editReaderActive = editReaderActive || menuHost32 != nullptr; } } MSG message{}; // Native lists, tabs, trees and combo boxes can omit change WinEvents in Wine. // Observe only the foreground control; never read background selections. const UINT_PTR listTimer = dialogReaderEnabled ? SetTimer(nullptr, 0, 100, nullptr) : 0; if (dialogReaderEnabled && !bootstrapperReader.start()) std::fprintf(stderr, "wine-access: bootstrapper UIA worker unavailable\n"); if (dialogReaderEnabled && !listTimer) std::fprintf(stderr, "wine-access: control polling timer unavailable\n"); // Callback availability is distinct from foreground attachment. There may // be no matching application when this startup snapshot is emitted. std::fprintf(stderr, "wine-access: ready {\"reader\":\"%s\",\"controls_enabled\":%s," "\"controller\":%s,\"review\":%s,\"msaa_worker\":%s,\"win_events\":%s," "\"menu_hook\":%s,\"input_hook\":%s," "\"menu_hook_available\":%s,\"input_hook_available\":%s," "\"host32_started\":%s,\"control_poll\":%s}\n", readerEndpoint.busName, dialogReaderEnabled ? "true" : "false", controllerActive ? "true" : "false", reviewActive ? "true" : "false", msaaActive ? "true" : "false", hook ? "true" : "false", foregroundHooks.menu ? "true" : "false", foregroundHooks.input ? "true" : "false", foregroundHooks.menu_callback ? "true" : "false", foregroundHooks.input_callback ? "true" : "false", menuHost32 ? "true" : "false", listTimer ? "true" : "false"); while (GetMessageW(&message, nullptr, 0, 0) > 0) { if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr && message.wParam == listTimer) { MsaaResult result; for (unsigned handled = 0; handled < 32 && msaaEvents.take(result); ++handled) present_msaa_result(result); if (pendingMsaaFocus.window && GetTickCount64() >= pendingMsaaFocus.deadline) { const auto source = pendingMsaaFocus; pendingMsaaFocus = {}; if (current_msaa_source(source)) present_standard_focus(source.window); } restore_virtual_desktop_focus(); wine_access_update_hooks(&foregroundHooks, FALSE); poll_control_focus(); reviewService.focus_changed(reviewIdentities.observe_foreground()); continue; } TranslateMessage(&message); DispatchMessageW(&message); } if (listTimer) KillTimer(nullptr, listTimer); readerEvents.stop(); msaaEvents.stop(); bootstrapperReader.stop(); reviewService.stop(); if (hook != nullptr) UnhookWinEvent(hook); wine_access_release_hooks(&foregroundHooks); if (menuBridge != nullptr) DestroyWindow(menuBridge); if (menuHost32 != nullptr) { // Destroying the bridge ends the host's loop. Parent-process death also // stops it, so a reader crash cannot leave an orphaned foreground hook. if (WaitForSingleObject(menuHost32, 1000) == WAIT_TIMEOUT) { TerminateProcess(menuHost32, 0); WaitForSingleObject(menuHost32, 1000); } CloseHandle(menuHost32); } if (menuModule != nullptr) FreeLibrary(menuModule); if (controllerActive) { reset_rpc_server(); } if (window != nullptr) DestroyWindow(window); CoUninitialize(); g_object_unref(connection); CloseHandle(singleton); return 0; }