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.

This commit is contained in:
Storm Dragon committed 2026-10-16 12:26:59 -04:00
1 parent 38358ad27a
commit e0c83859f5
13 files changed
+753 -11

No files matched your search

+7 -5
View File
@@ -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}")
+28
View File
@@ -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
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "bootstrapper_controls.h"
#include <initguid.h>
#include <uiautomation.h>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <utility>
namespace {
template<class T> 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<IUIAutomation> automation;
if (FAILED(CoCreateInstance(CLSID_CUIAutomation, nullptr, CLSCTX_INPROC_SERVER,
IID_IUIAutomation, reinterpret_cast<void **>(&automation.value))) || !current()) return result;
ComPtr<IUIAutomationCondition> condition;
ComPtr<IUIAutomationTreeWalker> walker;
ComPtr<IUIAutomationElement> 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<IUIAutomationElement> 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<bool> 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<std::shared_ptr<State>> retained(static_cast<std::shared_ptr<State> *>(pointer));
auto state = *retained;
const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
for (;;) {
HWND window;
uint64_t generation;
{
std::unique_lock<std::mutex> 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<std::mutex> 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<State>();
auto retained = new std::shared_ptr<State>(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<std::mutex> 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<std::mutex> 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<std::mutex> lock(state_->mutex);
state_->stopping = true; state_->pending = state_->ready = false;
state_->changed.notify_one();
}
WaitForSingleObject(worker_, 100);
CloseHandle(worker_); worker_ = nullptr; state_.reset();
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
#include <cstdint>
#include <memory>
#include <string>
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> state_;
HANDLE worker_ = nullptr;
};
+118
View File
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#define COBJMACROS
#include "bootstrapper_hook.h"
#include "menu_bridge.h"
#include <initguid.h>
#include <uiautomationcore.h>
#include <uiautomationcoreapi.h>
#include <uiautomationclient.h>
#include <string.h>
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);
}
+7
View File
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
#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);
+19 -1
View File
@@ -3,6 +3,7 @@
#include "menu_bridge.h"
#include "control_class.h"
#include "notification_hook.h"
#include "bootstrapper_hook.h"
#include <commctrl.h>
#include <stdlib.h>
@@ -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);
+71
View File
@@ -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 <commctrl.h>
@@ -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<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) {
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<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";
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<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) {
@@ -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);
+8
View File
@@ -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;
+14
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "menu_bridge.h"
#include "bootstrapper_hook.h"
#include <stdlib.h>
#include <stdio.h>
@@ -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);
+47
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h>
#include <uiautomation.h>
#include <cstdio>
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<IRawElementProviderSimple *>(this);
else if (IsEqualIID(iid, IID_IRawElementProviderFragment))
*out = static_cast<IRawElementProviderFragment *>(this);
else if (index == 0 && IsEqualIID(iid, IID_IRawElementProviderFragmentRoot))
*out = static_cast<IRawElementProviderFragmentRoot *>(this);
else if (index == 7 && IsEqualIID(iid, IID_IToggleProvider))
*out = static_cast<IToggleProvider *>(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<IToggleProvider *>(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<LONG>(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;
}
+47 -5
View File
@@ -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])