From e0c83859f5a019aa7b02809f82a549566a88a400 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 16 Oct 2026 12:26:59 -0400 Subject: [PATCH] Battle.net installer now working. It crashes when the login page loads, but that seems to be fixed in upcoming version and the controls did read. --- CMakeLists.txt | 12 +- README.md | 28 +++++ bootstrapper_controls.cpp | 209 +++++++++++++++++++++++++++++++++ bootstrapper_controls.h | 35 ++++++ bootstrapper_hook.c | 118 +++++++++++++++++++ bootstrapper_hook.h | 7 ++ edit_hook.c | 20 +++- main.cpp | 71 +++++++++++ menu_bridge.h | 8 ++ menu_host32.c | 14 +++ tests/README.md | 47 ++++++++ tests/bootstrapper_fixture.cpp | 143 ++++++++++++++++++++++ tests/menu_integration.py | 52 +++++++- 13 files changed, 753 insertions(+), 11 deletions(-) create mode 100644 bootstrapper_controls.cpp create mode 100644 bootstrapper_controls.h create mode 100644 bootstrapper_hook.c create mode 100644 bootstrapper_hook.h create mode 100644 tests/bootstrapper_fixture.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 39a241e..dd3301e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,8 +19,9 @@ add_custom_command(OUTPUT "${menu_hook}" -static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/scintilla_hook.c" -luser32 -lcomctl32 -lgdi32 - "${CMAKE_CURRENT_SOURCE_DIR}/notification_hook.c" -loleaut32 - DEPENDS menu_hook.c menu_bridge.h notification_hook.c notification_hook.h edit_hook.c edit_hook.h control_class.h scintilla_hook.c scintilla_messages.h + "${CMAKE_CURRENT_SOURCE_DIR}/notification_hook.c" + "${CMAKE_CURRENT_SOURCE_DIR}/bootstrapper_hook.c" -loleaut32 + DEPENDS menu_hook.c menu_bridge.h notification_hook.c notification_hook.h bootstrapper_hook.c bootstrapper_hook.h edit_hook.c edit_hook.h control_class.h scintilla_hook.c scintilla_messages.h VERBATIM) add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}") @@ -31,11 +32,12 @@ add_custom_command(OUTPUT "${menu_hook32}" "${menu_host32}" -static-libgcc -Wl,--kill-at -o "${menu_hook32}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32 - "${CMAKE_CURRENT_SOURCE_DIR}/notification_hook.c" -loleaut32 + "${CMAKE_CURRENT_SOURCE_DIR}/notification_hook.c" + "${CMAKE_CURRENT_SOURCE_DIR}/bootstrapper_hook.c" -loleaut32 COMMAND "${MINGW32_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -mwindows -static-libgcc -o "${menu_host32}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_host32.c" -luser32 - DEPENDS menu_hook.c menu_bridge.h notification_hook.c notification_hook.h menu_host32.c edit_hook.c edit_hook.h control_class.h + DEPENDS menu_hook.c menu_bridge.h notification_hook.c notification_hook.h bootstrapper_hook.c bootstrapper_hook.h menu_host32.c edit_hook.c edit_hook.h control_class.h VERBATIM) add_custom_target(wine-access-menu32 ALL DEPENDS "${menu_hook32}" "${menu_host32}") @@ -58,7 +60,7 @@ add_custom_command( DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl" VERBATIM) -add_executable(wine-access reader_events.cpp msaa_events.cpp main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}") +add_executable(wine-access reader_events.cpp msaa_events.cpp bootstrapper_controls.cpp main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}") add_dependencies(wine-access wine-access-menu wine-access-menu32 wine-access-version) target_compile_features(wine-access PRIVATE cxx_std_20) target_include_directories(wine-access PRIVATE "${generated_dir}") diff --git a/README.md b/README.md index 53be4b8..ae8b760 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,34 @@ The standard-control reader is deliberately limited to native Wine controls. Custom-drawn, DirectX, and game-specific accessibility APIs remain the responsibility of the application or its accessibility middleware. +Battle.net's `Blizzard Bootstrapper` wizard has a separate, guarded UI Automation +reader. Its language page exposes no MSAA child controls. The helper reads the +focused language selector and its current language, and the Continue button, +without activating controls or changing focus. The provider marks the selected +language as focused even while Continue owns Tab focus, so language choices +are presented as the selector's value rather than independent focus events. +Arrow changes are polled and unchanged results are suppressed. This support +also reads the second page's startup checkbox through its native Toggle +provider. Wine rejects the client toggle-state property query, so the native +input hook borrows the focused provider on the application's UI thread and +copies its checked/unchecked/mixed state. Only the foreground bootstrapper's +direct `UiaReturnRawElementProvider` import is observed; the original call and +result are preserved, no control is toggled, and provider references are not +retained. State changes use the existing reader's `state` event. + +After installing updated hooks, close and reopen the wizard as well as +restarting the reader. The hook stays mapped for the application's lifetime. +Later installer pages beyond these exposed controls and the Battle.net +launcher remain unverified. + +The UIA queries use a separate COM worker, limited to the foreground bootstrapper +class, 64 sibling controls and a 500 ms result deadline. Wine's implemented +extended property queries and an explicitly constructed tree walker avoid +unimplemented convenience APIs. Protected field values and values whose +protection is unknown are never queried. A blocked provider can stop this +wizard's feedback until it returns, but does not block native menus, editing, +MSAA events or helper shutdown. This is control feedback, not UIA flat review. + Native menu reading uses accompanying 64-bit and 32-bit Windows hook DLLs. They observe `WM_MENUSELECT` inside Wine applications and sends menu labels, displayed diff --git a/bootstrapper_controls.cpp b/bootstrapper_controls.cpp new file mode 100644 index 0000000..139510d --- /dev/null +++ b/bootstrapper_controls.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "bootstrapper_controls.h" +#include +#include +#include +#include +#include +#include + +namespace { +template struct ComPtr { + T *value = nullptr; + ~ComPtr() { if (value) value->Release(); } +}; +struct Property { + VARIANT value{}; + Property(IUIAutomationElement *element, PROPERTYID id) { + // Wine implements Ex; GetCurrentPropertyValue and many convenience + // getters return E_NOTIMPL in Wine 11.19. + if (FAILED(element->GetCurrentPropertyValueEx(id, TRUE, &value))) VariantClear(&value); + } + ~Property() { VariantClear(&value); } + bool is_true() const { return value.vt == VT_BOOL && value.boolVal == VARIANT_TRUE; } + bool is_false() const { return value.vt == VT_BOOL && value.boolVal == VARIANT_FALSE; } + std::string string() const { + if (value.vt != VT_BSTR || !value.bstrVal) return {}; + UINT length = SysStringLen(value.bstrVal); + if (length > 1023) length = 1023; + if (length && value.bstrVal[length - 1] >= 0xd800 && + value.bstrVal[length - 1] <= 0xdbff) --length; + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value.bstrVal, length, nullptr, 0, nullptr, nullptr); + std::string result(size > 0 ? size : 0, '\0'); + if (size > 0) WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value.bstrVal, length, result.data(), size, nullptr, nullptr); + return result; + } +}; +std::string control_role(LONG type) { + switch (type) { + case UIA_ButtonControlTypeId: return "push button"; + case UIA_ComboBoxControlTypeId: return "combo box"; + case UIA_CheckBoxControlTypeId: return "check box"; + case UIA_EditControlTypeId: return "edit"; + case UIA_HyperlinkControlTypeId: return "link"; + case UIA_GroupControlTypeId: return "grouping"; + default: return {}; + } +} +} + +bool is_bootstrapper_window(HWND window) { + WCHAR className[128]{}; + return window && GetClassNameW(window, className, 128) && + lstrcmpW(className, L"Blizzard Bootstrapper") == 0; +} + +BootstrapperControl read_bootstrapper_control(HWND window, uint64_t deadline) { + BootstrapperControl result; + if (!is_bootstrapper_window(window) || GetForegroundWindow() != window) return result; + result.window = window; + result.thread = GetWindowThreadProcessId(window, &result.process); + auto current = [&] { return GetTickCount64() < deadline && + GetForegroundWindow() == window && is_bootstrapper_window(window); }; + ComPtr automation; + if (FAILED(CoCreateInstance(CLSID_CUIAutomation, nullptr, CLSCTX_INPROC_SERVER, + IID_IUIAutomation, reinterpret_cast(&automation.value))) || !current()) return result; + ComPtr condition; + ComPtr walker; + ComPtr root; + if (FAILED(automation.value->CreateTrueCondition(&condition.value)) || !current() || + FAILED(automation.value->CreateTreeWalker(condition.value, &walker.value)) || !current() || + FAILED(automation.value->ElementFromHandle(window, &root.value)) || !current()) return result; + IUIAutomationElement *child = nullptr; + if (FAILED(walker.value->GetFirstChildElement(root.value, &child))) return result; + std::string selectedLanguage; + // The bootstrapper exposes its language choices as siblings. A radio choice + // keeps HasKeyboardFocus even when Continue owns Tab focus: use the focused + // combo/button as the owner, and a radio only as the combo's current value. + for (unsigned count = 0; child && count < 64 && current(); ++count) { + ComPtr element{child}; + child = nullptr; + Property focused(element.value, UIA_HasKeyboardFocusPropertyId); + if (current() && focused.is_true()) { + Property type(element.value, UIA_ControlTypePropertyId); + Property offscreen(element.value, UIA_IsOffscreenPropertyId); + if (current() && type.value.vt == VT_I4 && !offscreen.is_true()) { + Property name(element.value, UIA_NamePropertyId); + if (type.value.lVal == UIA_RadioButtonControlTypeId) { + selectedLanguage = name.string(); + } else if (result.role.empty()) { + result.role = control_role(type.value.lVal); + if (!result.role.empty()) { + result.name = name.string(); + // IDs are local to this window's observed sibling layout. Wine's + // GetRuntimeId is also a stub; no UIA object reference crosses the + // worker boundary or becomes an actionable review target. + result.identity = ":" + std::to_string(count) + ":" + std::to_string(type.value.lVal); + Property password(element.value, UIA_IsPasswordPropertyId); + // Unknown protection is deliberately insufficient to read a value. + if (password.is_false() && current()) { + Property value(element.value, UIA_ValueValuePropertyId); + result.value = value.string(); + } else if (password.is_true()) { + result.states = "password"; + } + Property enabled(element.value, UIA_IsEnabledPropertyId); + if (enabled.is_false()) { + if (!result.states.empty()) result.states += ", "; + result.states += "unavailable"; + } + if (type.value.lVal == UIA_CheckBoxControlTypeId && current()) { + Property checked(element.value, UIA_ToggleToggleStatePropertyId); + if (checked.value.vt == VT_I4 && checked.value.lVal >= ToggleState_Off && + checked.value.lVal <= ToggleState_Indeterminate) { + if (!result.states.empty()) result.states += ", "; + result.states += checked.value.lVal == ToggleState_On ? "checked" : + checked.value.lVal == ToggleState_Indeterminate ? "half checked" : "not checked"; + } + } + } + } + } + } + if (current()) walker.value->GetNextSiblingElement(element.value, &child); + } + // A partial traversal must not present a stale/ambiguous selection. + bool complete = !child; + if (child) child->Release(); + if (!complete || !current() || result.role.empty() || result.identity.empty()) return {}; + // This UIA provider describes its custom language selector as a Group. + if (result.role == "grouping" && !selectedLanguage.empty()) result.role = "combo box"; + if (result.role == "combo box" && !selectedLanguage.empty()) result.value = selectedLanguage; + result.available = true; + return result; +} + +struct BootstrapperControlReader::State { + std::mutex mutex; + std::condition_variable changed; + std::atomic stopping{false}; + HWND window = nullptr; + uint64_t generation = 0; + bool pending = false, busy = false, ready = false; + BootstrapperControl result; + static DWORD WINAPI work(LPVOID pointer) { + std::unique_ptr> retained(static_cast *>(pointer)); + auto state = *retained; + const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + for (;;) { + HWND window; + uint64_t generation; + { + std::unique_lock lock(state->mutex); + state->changed.wait(lock, [&] { return state->stopping || state->pending; }); + if (state->stopping) break; + window = state->window; generation = state->generation; + state->pending = false; state->busy = true; + } + const uint64_t deadline = GetTickCount64() + 500; + auto result = SUCCEEDED(com) ? read_bootstrapper_control(window, deadline) : BootstrapperControl{}; + result.generation = generation; result.deadline = deadline; + std::lock_guard lock(state->mutex); + state->busy = false; + if (!state->stopping && state->window == window && state->generation == generation) { + state->result = std::move(result); state->ready = true; + } + } + if (SUCCEEDED(com)) CoUninitialize(); + return 0; + } +}; +BootstrapperControlReader::~BootstrapperControlReader() { stop(); } +bool BootstrapperControlReader::start() { + if (state_) return false; + state_ = std::make_shared(); + auto retained = new std::shared_ptr(state_); + worker_ = CreateThread(nullptr, 0, State::work, retained, 0, nullptr); + if (!worker_) { delete retained; state_.reset(); return false; } + return true; +} +void BootstrapperControlReader::observe(HWND window) { + if (!state_) return; + std::lock_guard lock(state_->mutex); + if (state_->window != window) { + state_->window = window; ++state_->generation; + state_->pending = state_->ready = false; + } + if (window && !state_->stopping && !state_->busy && !state_->pending && !state_->ready) { + state_->pending = true; state_->changed.notify_one(); + } +} +bool BootstrapperControlReader::take(BootstrapperControl &result) { + if (!state_) return false; + std::lock_guard lock(state_->mutex); + if (!state_->ready) return false; + result = std::move(state_->result); state_->ready = false; + return true; +} +void BootstrapperControlReader::stop() { + if (!state_) return; + { + std::lock_guard lock(state_->mutex); + state_->stopping = true; state_->pending = state_->ready = false; + state_->changed.notify_one(); + } + WaitForSingleObject(worker_, 100); + CloseHandle(worker_); worker_ = nullptr; state_.reset(); +} diff --git a/bootstrapper_controls.h b/bootstrapper_controls.h new file mode 100644 index 0000000..acafaeb --- /dev/null +++ b/bootstrapper_controls.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include +#include +#include + +bool is_bootstrapper_window(HWND window); + +struct BootstrapperControl { + HWND window = nullptr; + DWORD process = 0, thread = 0; + uint64_t generation = 0, deadline = 0; + bool available = false; + std::string identity, name, role, value, states; +}; + +// Caller initializes COM. Queries only copied properties, never invokes or +// focuses a control. The deadline bounds traversal, not a blocked COM call. +BootstrapperControl read_bootstrapper_control(HWND window, uint64_t deadline); + +// A separate worker keeps a stalled UIA provider out of the MSAA, menu, +// editor and controller paths. At most one request and one result are kept. +class BootstrapperControlReader { +public: + ~BootstrapperControlReader(); + bool start(); + void observe(HWND window); + bool take(BootstrapperControl &result); + void stop(); +private: + struct State; + std::shared_ptr state_; + HANDLE worker_ = nullptr; +}; diff --git a/bootstrapper_hook.c b/bootstrapper_hook.c new file mode 100644 index 0000000..8001097 --- /dev/null +++ b/bootstrapper_hook.c @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#define COBJMACROS +#include "bootstrapper_hook.h" +#include "menu_bridge.h" +#include +#include +#include +#include +#include + +typedef LRESULT (WINAPI *ReturnProvider)(HWND, WPARAM, LPARAM, IRawElementProviderSimple *); +static ReturnProvider originalReturn; +static HWND queryWindow, queryBridge; +static LONG querying; + +static void read_toggle(IRawElementProviderSimple *root) { + IRawElementProviderFragmentRoot *fragmentRoot = NULL; + IRawElementProviderFragment *focus = NULL; + IRawElementProviderSimple *provider = NULL; + IUnknown *pattern = NULL; + IToggleProvider *toggle = NULL; + VARIANT type = {0}, name = {0}; + enum ToggleState state; + if (FAILED(IRawElementProviderSimple_QueryInterface(root, &IID_IRawElementProviderFragmentRoot, + (void **)&fragmentRoot)) || !fragmentRoot) goto done; + if (FAILED(IRawElementProviderFragmentRoot_GetFocus(fragmentRoot, &focus)) || !focus) goto done; + if (FAILED(IRawElementProviderFragment_QueryInterface(focus, &IID_IRawElementProviderSimple, + (void **)&provider)) || !provider) goto done; + if (FAILED(IRawElementProviderSimple_GetPropertyValue(provider, UIA_ControlTypePropertyId, &type)) || + type.vt != VT_I4 || type.lVal != UIA_CheckBoxControlTypeId) goto done; + if (FAILED(IRawElementProviderSimple_GetPatternProvider(provider, UIA_TogglePatternId, &pattern)) || + !pattern || FAILED(IUnknown_QueryInterface(pattern, &IID_IToggleProvider, (void **)&toggle)) || + !toggle || FAILED(IToggleProvider_get_ToggleState(toggle, &state)) || + (state != ToggleState_Off && state != ToggleState_On && state != ToggleState_Indeterminate)) goto done; + if (FAILED(IRawElementProviderSimple_GetPropertyValue(provider, UIA_NamePropertyId, &name)) || + name.vt != VT_BSTR || !name.bstrVal || GetForegroundWindow() != queryWindow) goto done; + WineAccessBootstrapperState event = {0}; + event.owner = (ULONGLONG)(UINT_PTR)queryWindow; + event.tick = GetTickCount64(); + event.state = state; + UINT length = SysStringLen(name.bstrVal); + if (length >= ARRAYSIZE(event.name)) goto done; + memcpy(event.name, name.bstrVal, length * sizeof(WCHAR)); + COPYDATASTRUCT data = {WINE_ACCESS_BOOTSTRAPPER_TAG, sizeof(event), &event}; + DWORD_PTR result; + SendMessageTimeoutW(queryBridge, WM_COPYDATA, 0, (LPARAM)&data, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result); +done: + VariantClear(&type); VariantClear(&name); + if (toggle) IToggleProvider_Release(toggle); + if (pattern) IUnknown_Release(pattern); + if (provider) IRawElementProviderSimple_Release(provider); + if (focus) IRawElementProviderFragment_Release(focus); + if (fragmentRoot) IRawElementProviderFragmentRoot_Release(fragmentRoot); +} + +static LRESULT WINAPI return_provider(HWND window, WPARAM wp, LPARAM lp, + IRawElementProviderSimple *provider) { + // Borrow the provider only during this original call, on its UI thread. + // Never retain it, toggle a control, or change the original UIA result. + if (provider && window == queryWindow && queryBridge && (LONG)lp == UiaRootObjectId) + read_toggle(provider); + return originalReturn(window, wp, lp, provider); +} + +static BOOL watch_import(void) { + BYTE *base = (BYTE *)GetModuleHandleW(NULL); + IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base; + if (!base || dos->e_magic != IMAGE_DOS_SIGNATURE) return FALSE; + IMAGE_NT_HEADERS *header = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew); + if (header->Signature != IMAGE_NT_SIGNATURE) return FALSE; + IMAGE_DATA_DIRECTORY directory = header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if (!directory.VirtualAddress || !directory.Size) return FALSE; + IMAGE_IMPORT_DESCRIPTOR *imports = (IMAGE_IMPORT_DESCRIPTOR *)(base + directory.VirtualAddress); + for (; imports->Name; ++imports) { + if (lstrcmpiA((char *)(base + imports->Name), "uiautomationcore.dll") || + !imports->OriginalFirstThunk) continue; + IMAGE_THUNK_DATA *names = (IMAGE_THUNK_DATA *)(base + imports->OriginalFirstThunk); + IMAGE_THUNK_DATA *addresses = (IMAGE_THUNK_DATA *)(base + imports->FirstThunk); + for (; names->u1.AddressOfData; ++names, ++addresses) { + if (IMAGE_SNAP_BY_ORDINAL(names->u1.Ordinal)) continue; + IMAGE_IMPORT_BY_NAME *name = (IMAGE_IMPORT_BY_NAME *)(base + names->u1.AddressOfData); + if (strcmp((char *)name->Name, "UiaReturnRawElementProvider")) continue; + if (addresses->u1.Function == (ULONG_PTR)return_provider) return TRUE; + HMODULE uia = GetModuleHandleW(L"uiautomationcore.dll"), pinned = NULL; + if (!uia) return FALSE; + FARPROC address = GetProcAddress(uia, "UiaReturnRawElementProvider"); + if (!address || addresses->u1.Function != (ULONG_PTR)address) return FALSE; + memcpy(&originalReturn, &address, sizeof(originalReturn)); + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + (LPCWSTR)(ULONG_PTR)return_provider, &pinned)) return FALSE; + DWORD protection; + if (!VirtualProtect(&addresses->u1.Function, sizeof(addresses->u1.Function), PAGE_READWRITE, + &protection)) return FALSE; + InterlockedExchangePointer((PVOID volatile *)&addresses->u1.Function, (PVOID)(ULONG_PTR)return_provider); + DWORD ignored; + VirtualProtect(&addresses->u1.Function, sizeof(addresses->u1.Function), protection, &ignored); + return TRUE; + } + } + return FALSE; +} + +void wine_access_query_bootstrapper_state(HWND window, HWND bridge) { + WCHAR className[128]; + DWORD process = 0; + if (!bridge || window != GetForegroundWindow() || + GetWindowThreadProcessId(window, &process) != GetCurrentThreadId() || + process != GetCurrentProcessId() || !GetClassNameW(window, className, ARRAYSIZE(className)) || + lstrcmpW(className, L"Blizzard Bootstrapper")) return; + if (InterlockedCompareExchange(&querying, 1, 0)) return; + if (watch_import()) { + queryWindow = window; queryBridge = bridge; + SendMessageW(window, WM_GETOBJECT, 0, UiaRootObjectId); + queryWindow = queryBridge = NULL; + } + InterlockedExchange(&querying, 0); +} diff --git a/bootstrapper_hook.h b/bootstrapper_hook.h new file mode 100644 index 0000000..996f826 --- /dev/null +++ b/bootstrapper_hook.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#define BOOTSTRAPPER_STATE_MESSAGE L"WineAccessBootstrapperState" +#define BOOTSTRAPPER_STATE_PENDING L"WineAccessBootstrapperStatePending" +#define BOOTSTRAPPER_STATE_READY L"WineAccessBootstrapperStateReady" +void wine_access_query_bootstrapper_state(HWND window, HWND bridge); diff --git a/edit_hook.c b/edit_hook.c index e7c1cd6..0c5ad3a 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -3,6 +3,7 @@ #include "menu_bridge.h" #include "control_class.h" #include "notification_hook.h" +#include "bootstrapper_hook.h" #include #include @@ -288,8 +289,25 @@ BOOL wine_access_watch_edit(HWND window) { __declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) { if (code >= 0 && wp == PM_REMOVE) { - wine_access_watch_notifications(); + const MSG *bootstrapperMessage = (const MSG *)lp; HWND focus = GetFocus(); + WCHAR className[128]; + if (focus && GetClassNameW(focus, className, ARRAYSIZE(className)) && + !lstrcmpW(className, L"Blizzard Bootstrapper")) { + HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL); + // Associate readiness with this reader instance, not just a DLL left + // mapped in the application after the previous reader exited. + if (bridge && GetPropW(focus, BOOTSTRAPPER_STATE_READY) != (HANDLE)bridge) { + RemovePropW(focus, BOOTSTRAPPER_STATE_PENDING); + SetPropW(focus, BOOTSTRAPPER_STATE_READY, (HANDLE)bridge); + } + } + if (bootstrapperMessage->message == RegisterWindowMessageW(BOOTSTRAPPER_STATE_MESSAGE)) { + wine_access_query_bootstrapper_state(bootstrapperMessage->hwnd, + FindWindowW(WINE_ACCESS_MENU_CLASS, NULL)); + RemovePropW(bootstrapperMessage->hwnd, BOOTSTRAPPER_STATE_PENDING); + } + wine_access_watch_notifications(); EditState *state; if (watch_edit(focus, &state)) { SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); diff --git a/main.cpp b/main.cpp index 75c7d46..f5e6568 100644 --- a/main.cpp +++ b/main.cpp @@ -12,6 +12,8 @@ #include "reader_endpoint.h" #include "msaa_events.h" #include "reader_events.h" +#include "bootstrapper_controls.h" +#include "bootstrapper_hook.h" #include "version.h" #include @@ -37,6 +39,10 @@ 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. @@ -500,6 +506,54 @@ void poll_control_focus(bool refresh = false) { 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) { + const std::string key = result.identity + "\n" + result.name + "\n" + + result.role + "\n" + result.value + "\n" + result.states; + 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"; + for (const char *flag : {"password", "unavailable"}) { + if (result.states.find(flag) != std::string::npos) control.states += std::string(", ") + flag; + } + } + const std::string presentation = key + "\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; @@ -693,6 +747,16 @@ void present_text_event(const WineAccessTextEvent &event) { 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) { @@ -812,6 +876,10 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, 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); @@ -1248,6 +1316,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { // 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"); // Initialization status is distinct from process creation. Hook handles and @@ -1282,6 +1352,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { if (listTimer) KillTimer(nullptr, listTimer); readerEvents.stop(); msaaEvents.stop(); + bootstrapperReader.stop(); reviewService.stop(); if (hook != nullptr) UnhookWinEvent(hook); diff --git a/menu_bridge.h b/menu_bridge.h index d4208c2..de80f9b 100644 --- a/menu_bridge.h +++ b/menu_bridge.h @@ -21,6 +21,14 @@ #define WINE_ACCESS_TEXT_SCINTILLA 1 #define WINE_ACCESS_TEXT_READONLY 2 #define WINE_ACCESS_NOTIFICATION_TAG 0x57414e31 +#define WINE_ACCESS_BOOTSTRAPPER_TAG 0x57414231 + +typedef struct WineAccessBootstrapperState { + ULONGLONG owner; + ULONGLONG tick; + UINT state; + WCHAR name[1024]; +} WineAccessBootstrapperState; typedef struct WineAccessNotificationEvent { DWORD process; diff --git a/menu_host32.c b/menu_host32.c index 07564a0..2aee674 100644 --- a/menu_host32.c +++ b/menu_host32.c @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "menu_bridge.h" +#include "bootstrapper_hook.h" #include #include @@ -61,6 +62,19 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) if (hook) UnhookWindowsHookEx(hook); if (inputHook) UnhookWindowsHookEx(inputHook); hook = inputHook = NULL; + } else { + HWND foreground = GetForegroundWindow(); + WCHAR className[128]; + if (GetWindowThreadProcessId(foreground, NULL) == hookedThread && + GetClassNameW(foreground, className, ARRAYSIZE(className)) && + !lstrcmpW(className, L"Blizzard Bootstrapper")) { + // A query can have passed through while this thread's hook was + // detached. Reestablish readiness on every attachment, including + // return from another workspace/application. + RemovePropW(foreground, BOOTSTRAPPER_STATE_READY); + RemovePropW(foreground, BOOTSTRAPPER_STATE_PENDING); + PostMessageW(foreground, WM_NULL, 0, 0); + } } } DWORD result = MsgWaitForMultipleObjects(1, &parent, FALSE, 100, QS_ALLINPUT); diff --git a/tests/README.md b/tests/README.md index 354424d..7bd81cb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,6 +41,53 @@ Check the runner without Wine: python3 tests/wine_test_environment_test.py ``` +## Battle.net bootstrapper controls + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture bootstrapper --32-bit +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture bootstrapper +``` + +Run these sequentially. The fixture supplies an actual custom UIA provider +without native child controls. A private recording reader verifies language +value changes, Continue focus despite a radio item retaining its focus flag, +return to the selector, duplicate suppression, background exclusion and return, +and exclusion of protected or unknown-protection values before querying them. +The real native input hook also reads a Toggle provider whose state is absent +from the provider's simple properties. It checks unchecked, checked and mixed +states, changes without focus movement, focus return and background exclusion. +The fixture fails if the helper invokes Toggle rather than reading its state. +The test does not use the real installer or send speech to the running reader. + +Read-only inspection on October 16 retrieved the actual running setup's +`Select a Language, combo box, English (United States)` and `Continue, push +button` through the new query code during Tab navigation. This verifies the +query path, not installed-helper speech or physical Braille. + +A later read-only native probe on the second page retrieved the startup +checkbox's real Toggle state while Wine's client state query returned +`E_NOTIMPL`. The first installed build was confirmed by the user to read the +language page and allow progress to the second screen; audible checkbox-state +acceptance remains separate. + +After building/installing Wine Access, restart Cthulhu and reopen the wizard +to load the new native hook. Return to the +wizard on workspace 2. Tab/Shift+Tab should distinguish the language selector +from Continue. With the selector focused, Down/Up should announce the current +language; restore the intended language afterward. Pause to check for repeated +announcements, then switch to Linux and back to check background silence and +focus recovery. Only activate Continue when ready to advance the installer; +later pages and the launcher remain unverified. Check ordinary Wine Notepad +typing/arrows, menus/Escape, native combo-box arrows and reader shutdown for +regressions. Verify audible speech and physical Braille separately. + +On the second page, Tab to `Launch Battle.net when you start your computer`: +expect its current checked/unchecked state. Press Space twice and verify each +new state immediately, leaving the original setting restored. Tab away and +Shift+Tab back should retain the correct state. Switching away must silence +background changes; returning should reestablish the state. Choose the desired +startup setting before continuing installation. + ## UIA notifications UIA notification routing uses the actual native application hooks and a private diff --git a/tests/bootstrapper_fixture.cpp b/tests/bootstrapper_fixture.cpp new file mode 100644 index 0000000..393849e --- /dev/null +++ b/tests/bootstrapper_fixture.cpp @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include +#include +#include + +static HWND window; +static int focusedIndex = 1, selectedIndex = 3, step = 0; +static ToggleState checkboxState = ToggleState_Off; +static bool checkboxEnabled = true; + +class Provider : public IRawElementProviderSimple, + public IRawElementProviderFragment, public IRawElementProviderFragmentRoot, public IToggleProvider { +public: + int index; + explicit Provider(int value) : index(value) {} + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void **out) override { + *out = nullptr; + if (IsEqualIID(iid, IID_IUnknown) || IsEqualIID(iid, IID_IRawElementProviderSimple)) + *out = static_cast(this); + else if (IsEqualIID(iid, IID_IRawElementProviderFragment)) + *out = static_cast(this); + else if (index == 0 && IsEqualIID(iid, IID_IRawElementProviderFragmentRoot)) + *out = static_cast(this); + else if (index == 7 && IsEqualIID(iid, IID_IToggleProvider)) + *out = static_cast(this); + return *out ? S_OK : E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { return 2; } + ULONG STDMETHODCALLTYPE Release() override { return 1; } + HRESULT STDMETHODCALLTYPE get_ProviderOptions(ProviderOptions *out) override { + *out = ProviderOptions_ServerSideProvider; return S_OK; + } + HRESULT STDMETHODCALLTYPE GetPatternProvider(PATTERNID id, IUnknown **out) override { + *out = index == 7 && id == UIA_TogglePatternId ? static_cast(this) : nullptr; + return S_OK; + } + HRESULT STDMETHODCALLTYPE Toggle() override { ExitProcess(9); } + HRESULT STDMETHODCALLTYPE get_ToggleState(ToggleState *out) override { *out = checkboxState; return S_OK; } + HRESULT STDMETHODCALLTYPE GetPropertyValue(PROPERTYID id, VARIANT *out) override { + VariantInit(out); + static const WCHAR *names[] = {L"Bootstrapper fixture", L"Select a Language", L"Continue", + L"English (United States)", L"French", L"Password", L"Unknown protection", L"Launch on startup"}; + if (id == UIA_NamePropertyId) { + out->vt = VT_BSTR; out->bstrVal = SysAllocString(names[index]); + } else if (id == UIA_ControlTypePropertyId) { + out->vt = VT_I4; + out->lVal = index == 7 ? UIA_CheckBoxControlTypeId : index == 0 ? UIA_WindowControlTypeId : index == 1 ? UIA_GroupControlTypeId : + index == 2 ? UIA_ButtonControlTypeId : index < 5 ? UIA_RadioButtonControlTypeId : UIA_EditControlTypeId; + } else if (id == UIA_HasKeyboardFocusPropertyId || id == UIA_IsKeyboardFocusablePropertyId || + id == UIA_IsOffscreenPropertyId || id == UIA_IsEnabledPropertyId || + id == UIA_IsPasswordPropertyId) { + if (id == UIA_IsPasswordPropertyId && index == 6) return S_OK; + out->vt = VT_BOOL; + bool value = id == UIA_HasKeyboardFocusPropertyId ? (index == focusedIndex || index == selectedIndex) : + id == UIA_IsKeyboardFocusablePropertyId ? index != 0 : + id == UIA_IsOffscreenPropertyId ? false : + id == UIA_IsPasswordPropertyId ? index == 5 : index != 7 || checkboxEnabled; + out->boolVal = value ? VARIANT_TRUE : VARIANT_FALSE; + } else if (id == UIA_ValueValuePropertyId && (index == 5 || index == 6)) { + // Protected and unknown-protection values must never even be queried. + ExitProcess(8); + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_HostRawElementProvider(IRawElementProviderSimple **out) override { + *out = nullptr; return index == 0 ? UiaHostProviderFromHwnd(window, out) : S_OK; + } + HRESULT STDMETHODCALLTYPE Navigate(NavigateDirection direction, IRawElementProviderFragment **out) override; + HRESULT STDMETHODCALLTYPE GetRuntimeId(SAFEARRAY **out) override { + *out = nullptr; + if (index == 0) return S_OK; + *out = SafeArrayCreateVector(VT_I4, 0, 2); + LONG position = 0, value = UiaAppendRuntimeId; + SafeArrayPutElement(*out, &position, &value); + position = 1; value = index; SafeArrayPutElement(*out, &position, &value); return S_OK; + } + HRESULT STDMETHODCALLTYPE get_BoundingRectangle(UiaRect *out) override { + *out = {100, 100.0 + index * 20, 200, 20}; return S_OK; + } + HRESULT STDMETHODCALLTYPE GetEmbeddedFragmentRoots(SAFEARRAY **out) override { *out = nullptr; return S_OK; } + HRESULT STDMETHODCALLTYPE SetFocus() override { return E_NOTIMPL; } + HRESULT STDMETHODCALLTYPE get_FragmentRoot(IRawElementProviderFragmentRoot **out) override; + HRESULT STDMETHODCALLTYPE ElementProviderFromPoint(double, double, IRawElementProviderFragment **out) override { + *out = nullptr; return S_OK; + } + HRESULT STDMETHODCALLTYPE GetFocus(IRawElementProviderFragment **out) override; +}; +static Provider providers[] = {Provider(0), Provider(1), Provider(2), Provider(3), Provider(4), Provider(5), Provider(6), Provider(7)}; +HRESULT Provider::Navigate(NavigateDirection direction, IRawElementProviderFragment **out) { + *out = nullptr; + if (direction == NavigateDirection_Parent && index) *out = &providers[0]; + else if (direction == NavigateDirection_FirstChild && index == 0) *out = &providers[1]; + else if (direction == NavigateDirection_LastChild && index == 0) *out = &providers[7]; + else if (direction == NavigateDirection_NextSibling && index > 0 && index < 7) *out = &providers[index + 1]; + else if (direction == NavigateDirection_PreviousSibling && index > 1) *out = &providers[index - 1]; + return S_OK; +} +HRESULT Provider::get_FragmentRoot(IRawElementProviderFragmentRoot **out) { *out = &providers[0]; return S_OK; } +HRESULT Provider::GetFocus(IRawElementProviderFragment **out) { *out = &providers[focusedIndex]; return S_OK; } + +static LRESULT CALLBACK window_proc(HWND hwnd, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_GETOBJECT && static_cast(lp) == UiaRootObjectId) + return UiaReturnRawElementProvider(hwnd, wp, lp, &providers[0]); + if (message == WM_TIMER) { + switch (++step) { + case 1: break; + case 2: selectedIndex = 4; break; + case 3: focusedIndex = 2; break; + case 4: focusedIndex = 1; break; + case 5: focusedIndex = 5; break; + case 6: focusedIndex = 6; break; + case 7: focusedIndex = 1; selectedIndex = 3; break; + case 8: focusedIndex = 7; break; + case 9: checkboxState = ToggleState_On; break; + case 10: checkboxState = ToggleState_Off; break; + case 11: checkboxState = ToggleState_Indeterminate; checkboxEnabled = false; break; + case 12: focusedIndex = 2; break; + case 13: focusedIndex = 7; break; + case 14: ShowWindow(hwnd, SW_HIDE); checkboxState = ToggleState_On; checkboxEnabled = true; break; + case 15: ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); SetFocus(hwnd); break; + default: DestroyWindow(hwnd); break; + } + return 0; + } + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(hwnd, message, wp, lp); +} +int WINAPI WinMain(HINSTANCE instance, HINSTANCE, LPSTR, int) { + if (FAILED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) return 1; + WNDCLASSW cls{}; + cls.hInstance = instance; cls.lpfnWndProc = window_proc; cls.lpszClassName = L"Blizzard Bootstrapper"; + RegisterClassW(&cls); + window = CreateWindowW(cls.lpszClassName, L"Bootstrapper fixture", WS_OVERLAPPEDWINDOW, + 100, 100, 400, 300, nullptr, nullptr, instance, nullptr); + ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(window); + SetTimer(window, 1, 800, nullptr); + MSG message{}; + while (GetMessageW(&message, nullptr, 0, 0) > 0) { + TranslateMessage(&message); DispatchMessageW(&message); + } + CoUninitialize(); + return 0; +} diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 7bd230a..64535fb 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -20,7 +20,7 @@ parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") parser.add_argument("--reader", choices=("cthulhu", "orca")) parser.add_argument("--extension-endpoint", action="store_true") -parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "listbox", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu") +parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "listbox", "delphi", "winforms", "controls", "scintilla", "goldwave", "bootstrapper"), default="menu") parser.add_argument("--notepadpp-exe", type=Path) parser.add_argument("--goldwave-exe", type=Path) parser.add_argument("--32-bit", dest="is32Bit", action="store_true") @@ -28,8 +28,8 @@ parser.add_argument("--late-helper", action="store_true") parser.add_argument("--restart-helper", action="store_true") add_test_options(parser) args = parser.parse_args() -if args.is32Bit and args.fixture not in ("menu", "edit", "winforms", "list", "listbox", "delphi", "controls"): - parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, listbox, delphi or controls") +if args.is32Bit and args.fixture not in ("menu", "edit", "winforms", "list", "listbox", "delphi", "controls", "bootstrapper"): + parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, listbox, delphi, controls or bootstrapper") if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"): parser.error("helper lifecycle checks require --fixture edit, winforms or scintilla") if args.fixture == "scintilla" and (not args.notepadpp_exe or not args.notepadpp_exe.is_file()): @@ -108,11 +108,16 @@ with wine_test_directory(prefix="wine-access-menus-") as temporary: sourceName = "edit" if args.fixture == "winforms" else args.fixture if args.fixture == "delphi": sourceName = "list" + isBootstrapper = args.fixture == "bootstrapper" + if isBootstrapper: + compiler = compiler.replace("gcc", "g++") subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", + *(["-static"] if isBootstrapper else []), *(["-DWINE_ACCESS_WINFORMS_FIXTURE"] if args.fixture == "winforms" else []), *(["-DWINE_ACCESS_DELPHI_FIXTURE"] if args.fixture == "delphi" else []), - "-o", str(fixture), str(Path(__file__).with_name(sourceName + "_fixture.c")), - "-luser32", "-lcomctl32", "-loleacc", "-lole32", "-ladvapi32"], check=True) + "-o", str(fixture), str(Path(__file__).with_name(sourceName + ("_fixture.cpp" if isBootstrapper else "_fixture.c"))), + "-luser32", "-lcomctl32", "-loleacc", "-lole32", "-ladvapi32", + *(["-luiautomationcore", "-loleaut32", "-luuid"] if isBootstrapper else [])], check=True) helper = None app = None try: @@ -160,6 +165,43 @@ with wine_test_directory(prefix="wine-access-menus-") as temporary: assert event[0] == focused, f"text sent without establishing focus: {event!r}" elif event[1] == "value" and event[3] == "combo box": assert event[0] == focused, f"combo value sent for an unfocused source: {event!r}; focus={focused}; events={events!r}" + if args.fixture == "bootstrapper": + uiaEvents = [e for e in events if ":bootstrapper-uia:" in e[0]] + for event in uiaEvents: + print(event[1:9]) + # Focus can arrive before the native hook's state reply. It must + # then be followed by a known state for that same focused source. + for index, event in enumerate(uiaEvents): + if event[3] == "check box" and not event[5]: + assert event[1] == "focus", f"lost a previously known checkbox state: {event!r}" + assert index + 1 < len(uiaEvents) and uiaEvents[index + 1][0] == event[0] and uiaEvents[index + 1][5], \ + f"checkbox state never arrived after focus: {uiaEvents[index:]!r}" + knownEvents = [e for e in uiaEvents if e[3] != "check box" or e[5]] + assert [e[2:5] for e in knownEvents] == [ + ("Select a Language", "combo box", "English (United States)"), + ("Select a Language", "combo box", "French"), + ("Continue", "push button", ""), + ("Select a Language", "combo box", "French"), + ("Password", "edit", ""), + ("Unknown protection", "edit", ""), + ("Select a Language", "combo box", "English (United States)"), + ("Launch on startup", "check box", ""), + ("Launch on startup", "check box", ""), + ("Launch on startup", "check box", ""), + ("Launch on startup", "check box", ""), + ("Continue", "push button", ""), + ("Launch on startup", "check box", ""), + ("Launch on startup", "check box", ""), + ], f"missing, repeated or background bootstrapper feedback: {uiaEvents!r}" + assert [e[1] for e in knownEvents[:7]] == ["focus", "value"] + ["focus"] * 5 + checkboxEvents = [e for e in knownEvents if e[3] == "check box"] + assert [e[1] for e in checkboxEvents[1:4]] == ["state"] * 3 + assert [e[5] for e in checkboxEvents] == [ + "not checked", "checked", "not checked", "half checked, unavailable", "half checked, unavailable", "checked"] + assert "password" in uiaEvents[4][5], "protected control lost its state" + assert len({e[0] for e in uiaEvents if e[3] == "combo box"}) == 1 + print("PASS: UIA selector values, native Toggle checked/unchecked/mixed, focus/return, deduplication, protected/unknown values, background exclusion") + sys.exit(0) if args.fixture == "goldwave": for event in events: print(event[1:9])