1401 lines
56 KiB
C++
1401 lines
56 KiB
C++
// 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 <commctrl.h>
|
|
#include <gio/gio.h>
|
|
#include <oleacc.h>
|
|
#include <rpc.h>
|
|
#include <windows.h>
|
|
#include <shellapi.h>
|
|
#include <uiautomationcore.h>
|
|
|
|
#include <cstdint>
|
|
#include <bit>
|
|
#include <cstdlib>
|
|
#include <cstdio>
|
|
#include <deque>
|
|
#include <iterator>
|
|
#include <string>
|
|
|
|
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<WineAccessMenuEvent> menuEvents;
|
|
std::deque<WineAccessTextEvent> textEvents;
|
|
std::deque<WineAccessNotificationEvent> 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<const gunichar2 *>(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<LPARAM>(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<std::uintptr_t>(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<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)) {
|
|
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);
|
|
lastListBoxControl = window;
|
|
lastListBoxIndex = static_cast<int>(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<LONG>(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<HANDLE>(bridge) && !GetPropW(bootstrapper, BOOTSTRAPPER_STATE_PENDING) &&
|
|
SetPropW(bootstrapper, BOOTSTRAPPER_STATE_PENDING, reinterpret_cast<HANDLE>(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<ULONGLONG>(reinterpret_cast<UINT_PTR>(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<DWORD_PTR>(-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<LONG_PTR>(count) < 0)
|
|
return;
|
|
if (info.hwndFocus != lastListBoxControl) {
|
|
present_standard_focus(info.hwndFocus);
|
|
} else if (refresh || static_cast<int>(selected) != lastListBoxIndex ||
|
|
static_cast<int>(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<HWND>(static_cast<UINT_PTR>(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<HWND>(static_cast<UINT_PTR>(event.owner));
|
|
HWND window = reinterpret_cast<HWND>(static_cast<UINT_PTR>(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<const COPYDATASTRUCT *>(lp);
|
|
if (data->dwData == WINE_ACCESS_BOOTSTRAPPER_TAG &&
|
|
data->cbData == sizeof(WineAccessBootstrapperState) && data->lpData) {
|
|
const auto event = *static_cast<const WineAccessBootstrapperState *>(data->lpData);
|
|
HWND owner = reinterpret_cast<HWND>(static_cast<UINT_PTR>(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<const WineAccessNotificationEvent *>(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<const WineAccessTextEvent *>(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<const WineAccessMenuEvent *>(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<size_t>(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<DWORD>(reinterpret_cast<UINT_PTR>(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<std::uintptr_t>(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<int>(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<RPC_WSTR>(const_cast<wchar_t *>(L"ncalrpc")),
|
|
RPC_C_PROTSEQ_MAX_REQS_DEFAULT,
|
|
reinterpret_cast<RPC_WSTR>(const_cast<wchar_t *>(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<RPC_WSTR>(const_cast<wchar_t *>(L"ncalrpc")),
|
|
RPC_C_PROTSEQ_MAX_REQS_DEFAULT, reinterpret_cast<RPC_WSTR>(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<error_status_t>(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<unsigned long>(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<unsigned long>(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<unsigned long>(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<HOOKPROC>(GetProcAddress(menuModule, "WineAccessMenuHook"));
|
|
if (!foregroundHooks.menu_callback) menuHookError = GetLastError();
|
|
foregroundHooks.input_callback = std::bit_cast<HOOKPROC>(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<unsigned long>(menuHookError));
|
|
if (inputHookError)
|
|
std::fprintf(stderr, "wine-access: native input hook unavailable (error %lu)\n",
|
|
static_cast<unsigned long>(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<unsigned long>(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;
|
|
}
|