Merge branch 'deedra'
Resolve tests/README.md by retaining both UIA notification and MSAA/reader reply stall guidance. Preserve master's notification hooks and Deedra's responsiveness fixes. Validation: CMake configure/build, all four CTest checks, nine stall integration variants, 64-bit and 32-bit UIA notification checks, menus, 64-bit and 32-bit editing, actual Wine Notepad, and git diff --check. Live speech and physical braille acceptance remain pending.
This commit is contained in:
commit
95cb78d3e4
12 files changed
+983
-66
No files matched your search
+1
-1
@@ -51,7 +51,7 @@ add_custom_command(
|
||||
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
|
||||
VERBATIM)
|
||||
|
||||
add_executable(cthulhu-wine-access main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}")
|
||||
add_executable(cthulhu-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_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32)
|
||||
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
|
||||
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
|
||||
|
||||
@@ -254,6 +254,28 @@ contains stable comma-separated state labels decoded from the MSAA bitmask;
|
||||
protected values are excluded. Readers should filter name/state changes to
|
||||
their focused source and distinguish these labels from editor diagnostics.
|
||||
|
||||
Application-owned MSAA queries run on one separate COM worker, with bounded
|
||||
pending requests and copied results. A stalled provider cannot block the
|
||||
helper's menu/editor event thread or normal shutdown. Focus/foreground changes
|
||||
retire previous results; event results expire after 500 ms. If focus queries
|
||||
do not finish, native control feedback uses the existing bounded fallback.
|
||||
Unrelated background events are rejected before querying providers; visible
|
||||
popups owned by the foreground window retain their accessible event path.
|
||||
A provider that never completes still occupies the one MSAA worker until
|
||||
helper exit; native menus, editing, focus fallback and controller requests
|
||||
remain available. No extra workers accumulate behind blocked providers.
|
||||
|
||||
Reader acknowledgments also run on a separate worker. Menu, editor and control
|
||||
events enter a bounded FIFO so a delayed D-Bus reply cannot block reception of
|
||||
the hooks' `WM_COPYDATA` messages. The event thread's success means queued;
|
||||
controller RPC requests still return the reader's actual status. At most 128
|
||||
events wait behind the active call. Each reply has a 500 ms timeout, and normal
|
||||
shutdown cancels the call. Failed exchanges and queue overflow produce concise
|
||||
stderr diagnostics without event text. After a failed exchange, editor actions
|
||||
re-establish their focus dependency; timed-out text actions are never replayed.
|
||||
An unavailable reader can still cause feedback loss or queue overflow.
|
||||
|
||||
|
||||
Standard `SysListView32` controls now report the current row's text columns,
|
||||
selection and position, including 32-bit installers driven by the 64-bit
|
||||
helper. Wine can expose these controls only as a generic client named `List1`,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include "review_service.h"
|
||||
#include "foreground_focus.h"
|
||||
#include "reader_endpoint.h"
|
||||
#include "msaa_events.h"
|
||||
#include "reader_events.h"
|
||||
|
||||
#include <commctrl.h>
|
||||
#include <gio/gio.h>
|
||||
@@ -33,6 +35,10 @@ constexpr int DBUS_TIMEOUT_MS = 500;
|
||||
GDBusConnection *connection = nullptr;
|
||||
ReviewIdentityStore reviewIdentities;
|
||||
ReviewService reviewService;
|
||||
MsaaEventReader msaaEvents;
|
||||
ReaderEventQueue readerEvents;
|
||||
uint64_t msaaGeneration = 0;
|
||||
MsaaEvent pendingMsaaFocus; // Event-thread owned; retired by focus changes, results or timeout.
|
||||
std::deque<WineAccessMenuEvent> menuEvents;
|
||||
std::deque<WineAccessTextEvent> textEvents;
|
||||
std::deque<WineAccessNotificationEvent> notificationEvents;
|
||||
@@ -156,31 +162,6 @@ bool reader_is_available() {
|
||||
return owned == TRUE;
|
||||
}
|
||||
|
||||
std::string variant_text(const VARIANT &value) {
|
||||
if (value.vt == VT_BSTR)
|
||||
return to_utf8(value.bstrVal);
|
||||
if (value.vt == VT_I4) {
|
||||
wchar_t buffer[128] = {};
|
||||
if (GetRoleTextW(value.lVal, buffer, 128) > 0)
|
||||
return to_utf8(buffer);
|
||||
return std::to_string(value.lVal);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string accessible_property(
|
||||
IAccessible *accessible,
|
||||
HRESULT (STDMETHODCALLTYPE IAccessible::*getter)(VARIANT, BSTR *),
|
||||
const VARIANT &child) {
|
||||
BSTR value = nullptr;
|
||||
const HRESULT result = (accessible->*getter)(child, &value);
|
||||
if (FAILED(result) || value == nullptr)
|
||||
return {};
|
||||
const std::string text = to_utf8(value);
|
||||
SysFreeString(value);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Standard-control queries follow the LGPL wine-a11y reader published by
|
||||
// Mudb0y, while presentation uses the selected reader's D-Bus interface.
|
||||
void strip_mnemonics(wchar_t *text) {
|
||||
@@ -289,13 +270,12 @@ bool present_accessible_event(const std::string &sourceId,
|
||||
// Readers can update their focus owner even when they suppress its output.
|
||||
if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text"))
|
||||
lastTextControl = nullptr;
|
||||
return call_boolean("PresentAccessibleEvent",
|
||||
g_variant_new(
|
||||
return readerEvents.submit(g_variant_new(
|
||||
"(sssssssiis)", sourceId.c_str(), eventType,
|
||||
control.name.c_str(), control.role.c_str(),
|
||||
control.value.c_str(), control.states.c_str(),
|
||||
control.description.c_str(), control.position,
|
||||
control.count, windowTitle.c_str())) == ERROR_SUCCESS;
|
||||
control.count, windowTitle.c_str()));
|
||||
}
|
||||
|
||||
ControlDescription describe_standard_control(HWND window) {
|
||||
@@ -785,6 +765,14 @@ HANDLE start_menu_host32(HWND bridge) {
|
||||
|
||||
void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||
LONG objectId, LONG childId, DWORD, DWORD) {
|
||||
const bool presentationEvent = event == EVENT_OBJECT_FOCUS || event == EVENT_OBJECT_NAMECHANGE ||
|
||||
event == EVENT_OBJECT_VALUECHANGE || event == EVENT_OBJECT_SELECTION || event == EVENT_OBJECT_STATECHANGE;
|
||||
if (presentationEvent && !msaa_event_in_foreground(window)) return;
|
||||
if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND) {
|
||||
pendingMsaaFocus = {};
|
||||
++msaaGeneration;
|
||||
msaaEvents.invalidate(msaaGeneration);
|
||||
}
|
||||
if (event == EVENT_OBJECT_DESTROY) reviewIdentities.destroyed(window,objectId,childId);
|
||||
else if (event == EVENT_OBJECT_REORDER) reviewIdentities.reordered(window,objectId);
|
||||
if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND)
|
||||
@@ -824,46 +812,38 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||
else poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE);
|
||||
return;
|
||||
}
|
||||
IAccessible *accessible = nullptr;
|
||||
VARIANT child{};
|
||||
VariantInit(&child);
|
||||
if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible,
|
||||
&child)) ||
|
||||
accessible == nullptr) {
|
||||
if (event == EVENT_OBJECT_FOCUS)
|
||||
present_standard_focus(window);
|
||||
return;
|
||||
DWORD process = 0;
|
||||
DWORD thread = GetWindowThreadProcessId(window, &process);
|
||||
MsaaEvent request{window, event, process, thread, objectId, childId,
|
||||
msaaGeneration, GetTickCount64() + 500};
|
||||
if (event == EVENT_OBJECT_FOCUS) pendingMsaaFocus = request;
|
||||
msaaEvents.submit(request);
|
||||
}
|
||||
|
||||
const std::string name =
|
||||
accessible_property(accessible, &IAccessible::get_accName, child);
|
||||
const std::string value =
|
||||
accessible_property(accessible, &IAccessible::get_accValue, child);
|
||||
const std::string description =
|
||||
accessible_property(accessible, &IAccessible::get_accDescription, child);
|
||||
VARIANT role{};
|
||||
VariantInit(&role);
|
||||
std::string roleText;
|
||||
LONG roleId = 0;
|
||||
if (SUCCEEDED(accessible->get_accRole(child, &role)))
|
||||
roleText = variant_text(role);
|
||||
if (role.vt == VT_I4) roleId = role.lVal;
|
||||
VariantClear(&role);
|
||||
VARIANT state{};
|
||||
VariantInit(&state);
|
||||
std::string stateText;
|
||||
bool password = false;
|
||||
if (SUCCEEDED(accessible->get_accState(child, &state)) && state.vt == VT_I4) {
|
||||
stateText = msaa_states(static_cast<DWORD>(state.lVal), roleId);
|
||||
password = state.lVal & STATE_SYSTEM_PROTECTED;
|
||||
bool current_msaa_source(const MsaaEvent &source) {
|
||||
DWORD process = 0;
|
||||
DWORD thread = GetWindowThreadProcessId(source.window, &process);
|
||||
return source.generation == msaaGeneration && IsWindow(source.window) &&
|
||||
process == source.process && thread == source.thread &&
|
||||
msaa_event_in_foreground(source.window);
|
||||
}
|
||||
VariantClear(&state);
|
||||
LONG childCount = 0;
|
||||
accessible->get_accChildCount(&childCount);
|
||||
const bool nativeClient = child.vt == VT_I4 && child.lVal == CHILDID_SELF &&
|
||||
(roleText.empty() || roleId == ROLE_SYSTEM_CLIENT);
|
||||
accessible->Release();
|
||||
VariantClear(&child);
|
||||
|
||||
void present_msaa_result(const MsaaResult &result) {
|
||||
const auto &source = result.source;
|
||||
if (!current_msaa_source(source) || GetTickCount64() >= source.deadline) return;
|
||||
if (source.event == EVENT_OBJECT_FOCUS && pendingMsaaFocus.generation == source.generation)
|
||||
pendingMsaaFocus = {};
|
||||
HWND window = source.window;
|
||||
DWORD event = source.event;
|
||||
LONG objectId = source.object, childId = source.child;
|
||||
if (!result.available) {
|
||||
if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window);
|
||||
return;
|
||||
}
|
||||
const auto &name = result.name, &value = result.value, &description = result.description;
|
||||
const auto &roleText = result.role, &stateText = result.states;
|
||||
bool password = result.password, nativeClient = result.native_client;
|
||||
LONG childCount = result.count;
|
||||
|
||||
if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() &&
|
||||
description.empty() && (roleText.empty() || roleText == "client") &&
|
||||
@@ -1083,7 +1063,17 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
}
|
||||
|
||||
HWND window = nullptr;
|
||||
if (dialogReaderEnabled && !readerEvents.start(connection, readerEndpoint)) {
|
||||
std::fprintf(stderr, "wine-access: reader event worker unavailable\n");
|
||||
CoUninitialize();
|
||||
g_object_unref(connection);
|
||||
CloseHandle(singleton);
|
||||
return 1;
|
||||
}
|
||||
reviewService.start(connection,reviewIdentities,readerEndpoint);
|
||||
if (dialogReaderEnabled && !msaaEvents.start()) {
|
||||
std::fprintf(stderr, "wine-access: MSAA event worker unavailable\n");
|
||||
}
|
||||
bool controllerActive = false;
|
||||
if (controllerEnabled &&
|
||||
FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) {
|
||||
@@ -1148,6 +1138,14 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
|
||||
message.wParam == listTimer) {
|
||||
MsaaResult result;
|
||||
for (unsigned handled = 0; handled < 32 && msaaEvents.take(result); ++handled)
|
||||
present_msaa_result(result);
|
||||
if (pendingMsaaFocus.window && GetTickCount64() >= pendingMsaaFocus.deadline) {
|
||||
const auto source = pendingMsaaFocus;
|
||||
pendingMsaaFocus = {};
|
||||
if (current_msaa_source(source)) present_standard_focus(source.window);
|
||||
}
|
||||
restore_virtual_desktop_focus();
|
||||
poll_control_focus();
|
||||
reviewService.focus_changed(reviewIdentities.observe_foreground());
|
||||
@@ -1157,6 +1155,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
if (listTimer) KillTimer(nullptr, listTimer);
|
||||
readerEvents.stop();
|
||||
msaaEvents.stop();
|
||||
reviewService.stop();
|
||||
if (hook != nullptr)
|
||||
UnhookWinEvent(hook);
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "msaa_events.h"
|
||||
#include "msaa_states.h"
|
||||
#include <oleacc.h>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
#include <deque>
|
||||
#include <atomic>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
// MSAA notifications describe transient state, unlike ordered editor actions.
|
||||
// Bound pending work and favor current focus if an application floods events.
|
||||
constexpr size_t capacity = 32;
|
||||
std::string text(BSTR value) {
|
||||
if (!value) return {};
|
||||
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (size <= 1) return {};
|
||||
std::string result(size, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, -1, result.data(), size, nullptr, nullptr);
|
||||
result.resize(size - 1);
|
||||
return result;
|
||||
}
|
||||
std::string property(IAccessible *object,
|
||||
HRESULT (STDMETHODCALLTYPE IAccessible::*getter)(VARIANT, BSTR *), VARIANT child) {
|
||||
BSTR value = nullptr;
|
||||
HRESULT hr = (object->*getter)(child, &value);
|
||||
auto result = SUCCEEDED(hr) ? text(value) : std::string{};
|
||||
SysFreeString(value);
|
||||
return result;
|
||||
}
|
||||
struct Accessible {
|
||||
IAccessible *object = nullptr;
|
||||
VARIANT child{};
|
||||
~Accessible() { if (object) object->Release(); VariantClear(&child); }
|
||||
};
|
||||
}
|
||||
|
||||
bool msaa_event_in_foreground(HWND window) {
|
||||
HWND foreground = GetForegroundWindow();
|
||||
HWND root = GetAncestor(window, GA_ROOT);
|
||||
if (!foreground || !root) return false;
|
||||
if (root == foreground) return true;
|
||||
if (!IsWindowVisible(root) || !(GetWindowLongPtrW(root, GWL_STYLE) & WS_POPUP)) return false;
|
||||
// Bound traversal even if a malformed owner chain cycles. Owned popups can
|
||||
// expose MSAA focus while the foreground owner retains native keyboard focus.
|
||||
for (unsigned depth = 0; root && depth < 32; ++depth) {
|
||||
root = GetWindow(root, GW_OWNER);
|
||||
if (root == foreground) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
struct MsaaEventReader::State {
|
||||
std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
std::deque<MsaaEvent> pending;
|
||||
std::deque<MsaaResult> ready;
|
||||
std::atomic<bool> stopping{false};
|
||||
uint64_t generation = 0;
|
||||
bool current(const MsaaEvent &event) {
|
||||
if (stopping || GetTickCount64() >= event.deadline ||
|
||||
!msaa_event_in_foreground(event.window)) return false;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return generation == event.generation;
|
||||
}
|
||||
MsaaResult read(const MsaaEvent &event) {
|
||||
MsaaResult result; result.source = event;
|
||||
Accessible accessible;
|
||||
if (!current(event) || FAILED(AccessibleObjectFromEvent(event.window, event.object,
|
||||
event.child, &accessible.object, &accessible.child)) || !accessible.object)
|
||||
return result;
|
||||
if (!current(event)) return result;
|
||||
VARIANT role{};
|
||||
LONG roleId = 0;
|
||||
if (SUCCEEDED(accessible.object->get_accRole(accessible.child, &role))) {
|
||||
if (role.vt == VT_I4) {
|
||||
roleId = role.lVal;
|
||||
WCHAR label[128]{};
|
||||
if (GetRoleTextW(roleId, label, 128)) result.role = text(label);
|
||||
else result.role = std::to_string(roleId);
|
||||
} else if (role.vt == VT_BSTR) result.role = text(role.bstrVal);
|
||||
}
|
||||
VariantClear(&role);
|
||||
if (!current(event)) return result;
|
||||
VARIANT state{};
|
||||
if (SUCCEEDED(accessible.object->get_accState(accessible.child, &state)) && state.vt == VT_I4) {
|
||||
result.states = msaa_states(static_cast<DWORD>(state.lVal), roleId);
|
||||
result.password = state.lVal & STATE_SYSTEM_PROTECTED;
|
||||
}
|
||||
VariantClear(&state);
|
||||
if (!current(event)) return result;
|
||||
result.name = property(accessible.object, &IAccessible::get_accName, accessible.child);
|
||||
if (!current(event)) return result;
|
||||
// Protected values must be excluded before querying the provider.
|
||||
if (!result.password) result.value = property(accessible.object, &IAccessible::get_accValue, accessible.child);
|
||||
if (!current(event)) return result;
|
||||
result.description = property(accessible.object, &IAccessible::get_accDescription, accessible.child);
|
||||
if (!current(event)) return result;
|
||||
accessible.object->get_accChildCount(&result.count);
|
||||
result.native_client = accessible.child.vt == VT_I4 && accessible.child.lVal == CHILDID_SELF &&
|
||||
(result.role.empty() || roleId == ROLE_SYSTEM_CLIENT);
|
||||
result.available = true;
|
||||
return 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_APARTMENTTHREADED);
|
||||
for (;;) {
|
||||
MsaaEvent event;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state->mutex);
|
||||
state->changed.wait(lock, [&] { return state->stopping || !state->pending.empty(); });
|
||||
if (state->stopping) break;
|
||||
event = state->pending.front(); state->pending.pop_front();
|
||||
}
|
||||
auto result = SUCCEEDED(com) ? state->read(event) : MsaaResult{};
|
||||
result.source = event;
|
||||
if (!state->current(event)) continue;
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (state->stopping || event.generation != state->generation) continue;
|
||||
if (state->ready.size() == capacity) state->ready.pop_front();
|
||||
state->ready.push_back(std::move(result));
|
||||
}
|
||||
if (SUCCEEDED(com)) CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
MsaaEventReader::~MsaaEventReader() { stop(); }
|
||||
bool MsaaEventReader::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 MsaaEventReader::invalidate(uint64_t generation) {
|
||||
if (!state_) return;
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->generation = generation;
|
||||
state_->pending.clear(); state_->ready.clear();
|
||||
}
|
||||
void MsaaEventReader::submit(const MsaaEvent &event) {
|
||||
if (!state_) return;
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
if (state_->stopping || state_->generation != event.generation) return;
|
||||
if (state_->pending.size() == capacity) state_->pending.pop_front();
|
||||
state_->pending.push_back(event); state_->changed.notify_one();
|
||||
}
|
||||
bool MsaaEventReader::take(MsaaResult &result) {
|
||||
if (!state_) return false;
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
if (state_->ready.empty()) return false;
|
||||
result = std::move(state_->ready.front()); state_->ready.pop_front();
|
||||
return true;
|
||||
}
|
||||
void MsaaEventReader::stop() {
|
||||
if (!state_) return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->stopping = true; state_->pending.clear(); state_->ready.clear();
|
||||
state_->changed.notify_one();
|
||||
}
|
||||
// A hung COM call cannot be safely terminated. It retains only its own
|
||||
// shared state and native references until completion or process exit.
|
||||
WaitForSingleObject(worker_, 100);
|
||||
CloseHandle(worker_); worker_ = nullptr;
|
||||
state_.reset();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
// Native focus owners can keep keyboard focus while their visible popup
|
||||
// reports accessible item focus. Unrelated windows are never event sources.
|
||||
bool msaa_event_in_foreground(HWND window);
|
||||
|
||||
struct MsaaEvent {
|
||||
HWND window = nullptr;
|
||||
DWORD event = 0, process = 0, thread = 0;
|
||||
LONG object = 0, child = 0;
|
||||
uint64_t generation = 0, deadline = 0;
|
||||
};
|
||||
struct MsaaResult {
|
||||
MsaaEvent source;
|
||||
bool available = false, native_client = false, password = false;
|
||||
std::string name, role, value, states, description;
|
||||
LONG count = 0;
|
||||
};
|
||||
|
||||
// One COM worker owns native references. The event thread exchanges only
|
||||
// copied requests/results, and never waits for a provider to finish.
|
||||
class MsaaEventReader {
|
||||
public:
|
||||
~MsaaEventReader();
|
||||
bool start();
|
||||
void invalidate(uint64_t generation);
|
||||
void submit(const MsaaEvent &event);
|
||||
bool take(MsaaResult &result);
|
||||
void stop();
|
||||
private:
|
||||
struct State;
|
||||
std::shared_ptr<State> state_;
|
||||
HANDLE worker_ = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "reader_events.h"
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
struct ReaderEventQueue::State {
|
||||
State(GDBusConnection *bus, const ReaderEndpoint &reader)
|
||||
: connection(G_DBUS_CONNECTION(g_object_ref(bus))), endpoint(reader),
|
||||
calls(g_cancellable_new()) {}
|
||||
~State() {
|
||||
clear();
|
||||
g_object_unref(calls);
|
||||
g_object_unref(connection);
|
||||
}
|
||||
GDBusConnection *connection;
|
||||
const ReaderEndpoint endpoint;
|
||||
GCancellable *calls;
|
||||
std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
std::deque<GVariant *> pending;
|
||||
bool stopping = false, overflowReported = false;
|
||||
|
||||
void clear() {
|
||||
for (auto *parameters : pending) g_variant_unref(parameters);
|
||||
pending.clear();
|
||||
}
|
||||
bool send(GVariant *parameters, unsigned &failures) {
|
||||
GError *error = nullptr;
|
||||
GVariant *reply = g_dbus_connection_call_sync(
|
||||
connection, endpoint.busName, endpoint.objectPath,
|
||||
endpoint.interfaceName, "PresentAccessibleEvent", parameters,
|
||||
G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, 500, calls, &error);
|
||||
// A false reply can simply mean filtered/duplicate feedback. Only a
|
||||
// failed exchange warrants diagnostics. Never include event contents.
|
||||
const bool delivered = reply != nullptr;
|
||||
if (!delivered && !g_cancellable_is_cancelled(calls)) {
|
||||
if (++failures == 1)
|
||||
std::fprintf(stderr, "wine-access: reader event reply failed (domain %s, code %d)\n",
|
||||
error ? g_quark_to_string(error->domain) : "unknown", error ? error->code : 0);
|
||||
} else if (delivered && failures) {
|
||||
std::fprintf(stderr, "wine-access: reader event replies resumed after %u failed calls\n", failures);
|
||||
failures = 0;
|
||||
}
|
||||
if (reply) g_variant_unref(reply);
|
||||
if (error) g_error_free(error);
|
||||
return delivered;
|
||||
}
|
||||
static DWORD WINAPI work(LPVOID pointer) {
|
||||
std::unique_ptr<std::shared_ptr<State>> retained(
|
||||
static_cast<std::shared_ptr<State> *>(pointer));
|
||||
auto state = *retained;
|
||||
unsigned failures = 0;
|
||||
std::unique_ptr<GVariant, decltype(&g_variant_unref)> focus(nullptr, g_variant_unref);
|
||||
std::string focusSource;
|
||||
bool focusDelivered = false;
|
||||
for (;;) {
|
||||
GVariant *parameters;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state->mutex);
|
||||
state->changed.wait(lock, [&] { return state->stopping || !state->pending.empty(); });
|
||||
if (state->stopping) break;
|
||||
parameters = state->pending.front();
|
||||
state->pending.pop_front();
|
||||
}
|
||||
const char *source = nullptr, *kind = nullptr;
|
||||
g_variant_get_child(parameters, 0, "&s", &source);
|
||||
g_variant_get_child(parameters, 1, "&s", &kind);
|
||||
if (std::string(kind) == "focus") {
|
||||
focus.reset(g_variant_ref(parameters));
|
||||
focusSource = source;
|
||||
focusDelivered = state->send(parameters, failures);
|
||||
} else {
|
||||
// A lost focus exchange must not strand later editor actions. Replay
|
||||
// only the focus dependency, never a timed-out ordered text action.
|
||||
if (std::string(kind) == "text" && !focusDelivered && focus && focusSource == source)
|
||||
focusDelivered = state->send(focus.get(), failures);
|
||||
if (std::string(kind) != "text" || focusDelivered) {
|
||||
if (!state->send(parameters, failures)) focusDelivered = false;
|
||||
}
|
||||
}
|
||||
g_variant_unref(parameters);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
ReaderEventQueue::~ReaderEventQueue() { stop(); }
|
||||
|
||||
bool ReaderEventQueue::start(GDBusConnection *connection, const ReaderEndpoint &endpoint) {
|
||||
if (state_) return false;
|
||||
state_ = std::make_shared<State>(connection, endpoint);
|
||||
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;
|
||||
}
|
||||
|
||||
bool ReaderEventQueue::submit(GVariant *parameters) {
|
||||
parameters = g_variant_ref_sink(parameters);
|
||||
if (state_) {
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
// Keep memory bounded if the reader remains unavailable. Do not reorder
|
||||
// actions or evict the focus on which queued editor actions depend.
|
||||
if (!state_->stopping && state_->pending.size() < 128) {
|
||||
state_->pending.push_back(parameters);
|
||||
state_->changed.notify_one();
|
||||
return true;
|
||||
}
|
||||
if (!state_->stopping && !state_->overflowReported) {
|
||||
std::fprintf(stderr, "wine-access: reader event queue full; feedback dropped\n");
|
||||
state_->overflowReported = true;
|
||||
}
|
||||
}
|
||||
g_variant_unref(parameters);
|
||||
return false;
|
||||
}
|
||||
|
||||
void ReaderEventQueue::stop() {
|
||||
if (!state_) return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->stopping = true;
|
||||
state_->clear();
|
||||
state_->changed.notify_one();
|
||||
}
|
||||
g_cancellable_cancel(state_->calls);
|
||||
// The worker retains its connection and queue state if cancellation has
|
||||
// not completed yet; it never references event-thread globals.
|
||||
WaitForSingleObject(worker_, 100);
|
||||
CloseHandle(worker_);
|
||||
worker_ = nullptr;
|
||||
state_.reset();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#include "reader_endpoint.h"
|
||||
#include <gio/gio.h>
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <memory>
|
||||
|
||||
// Event callbacks enqueue copied data. One worker preserves event order while
|
||||
// waiting for reader acknowledgments, leaving WM_COPYDATA reception available.
|
||||
class ReaderEventQueue {
|
||||
public:
|
||||
~ReaderEventQueue();
|
||||
bool start(GDBusConnection *connection, const ReaderEndpoint &endpoint);
|
||||
bool submit(GVariant *parameters);
|
||||
void stop();
|
||||
private:
|
||||
struct State;
|
||||
std::shared_ptr<State> state_;
|
||||
HANDLE worker_ = nullptr;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
# Wine control integration
|
||||
|
||||
## UIA notifications
|
||||
|
||||
UIA notification routing uses the actual native application hooks and a private
|
||||
recording reader, without sending speech to the live reader:
|
||||
|
||||
@@ -23,6 +25,67 @@ in Arcadelux, verify the introduction and repeated Up/Down menu announcements,
|
||||
including switching away and back. Also check existing NVDA/Tolk speech and
|
||||
ordinary Wine menus and Edit controls before submitting the change.
|
||||
|
||||
## Reader reply stalls
|
||||
|
||||
Run sequentially on a private X display:
|
||||
|
||||
```sh
|
||||
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build
|
||||
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build --32-bit
|
||||
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build --timeout
|
||||
xvfb-run -a python3 tests/reader_stall_integration.py --build-dir build --focus-error
|
||||
```
|
||||
|
||||
The recording reader delays an editor focus reply for 350 ms while its Wine
|
||||
control types `abbacdef`. All eight actions, including repeated letters, must
|
||||
arrive in order with focus established first, and native text and caret must
|
||||
remain correct. `--timeout` extends the delay past the helper's D-Bus deadline;
|
||||
`--focus-error` rejects the initial focus exchange and requires recovery before
|
||||
later text. A second withheld reply checks normal helper shutdown. The fixture
|
||||
waits for editor-hook attachment before typing, including the 32-bit host.
|
||||
|
||||
The original synchronous helper dropped six of the eight characters during
|
||||
the delayed-reply check. These private recordings establish that shared failure
|
||||
path; they do not establish the cause of every live MUSHclient speech pause.
|
||||
After installing the helper and restarting it, check command typing, incoming
|
||||
plugin speech, output-buffer arrows, menus, return from Linux applications and
|
||||
reader restart in MUSHclient. Check ordinary Notepad editing and password
|
||||
exclusion too. Audible speech and physical braille need separate live checks.
|
||||
|
||||
## MSAA event stalls
|
||||
|
||||
Run sequentially on a private X display, for example with `xvfb-run -a`:
|
||||
|
||||
```sh
|
||||
python3 tests/event_stall_integration.py --build-dir build
|
||||
python3 tests/event_stall_integration.py --build-dir build --32-bit
|
||||
python3 tests/event_stall_integration.py --build-dir build --getter
|
||||
python3 tests/event_stall_integration.py --build-dir build --background
|
||||
python3 tests/event_stall_integration.py --build-dir build --popup
|
||||
```
|
||||
|
||||
The fixture needs the matching MinGW C++ compiler. It uses a disposable prefix
|
||||
and recording reader service. It stalls either `WM_GETOBJECT` or MSAA
|
||||
text-property retrieval for eight seconds. A separate foreground
|
||||
application must deliver both repeated typed characters and native button
|
||||
feedback before that stall ends, preserving native text, caret and selection.
|
||||
The old focus must not surface afterward. A second stall checks normal
|
||||
`WM_QUIT` shutdown before the provider completes. Background focus events must
|
||||
neither query unrelated providers nor invalidate foreground feedback, and an
|
||||
owned nonactivating accessible popup must retain reading without moving native
|
||||
focus. Use `--reader orca` to check the alternate recording endpoint.
|
||||
|
||||
On October 12 the supplied Cthulhu log showed successful reader D-Bus calls,
|
||||
continued Xorg input and clean reader shutdown; it did not establish a reader
|
||||
crash. Source inspection found synchronous MSAA calls on the helper event
|
||||
thread. The original helper failed the blocked-provider typing check; the same
|
||||
editor fixture without a blocked provider passed. These synthetic checks
|
||||
establish the shared hang path, not the exact cause of every live MUSHclient
|
||||
failure. Recheck MUSHclient command typing, incoming plugin output, menus,
|
||||
plugin output-buffer arrows and keypad review after installing the helper and
|
||||
restarting the reader. Listen to speech and check physical braille separately.
|
||||
|
||||
|
||||
## Reader endpoint selection
|
||||
|
||||
After building, run these checks sequentially on private displays:
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include <windows.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "../menu_bridge.h"
|
||||
|
||||
static BOOL editor_mode, stalled, getter_mode, background_mode, popup_mode;
|
||||
static HWND editor, button, provider;
|
||||
static WNDPROC native_provider_proc;
|
||||
static unsigned step;
|
||||
static const WCHAR *cls_name = L"WineEventPopupFixture";
|
||||
static void marker(const WCHAR *variable) {
|
||||
WCHAR path[4096];
|
||||
if (!GetEnvironmentVariableW(variable, path, 4096)) return;
|
||||
HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
if (file != INVALID_HANDLE_VALUE) CloseHandle(file);
|
||||
}
|
||||
static void stall_provider() {
|
||||
if (stalled) return;
|
||||
stalled = TRUE;
|
||||
marker(L"WINE_ACCESS_STALL_READY");
|
||||
Sleep(8000);
|
||||
marker(L"WINE_ACCESS_STALL_DONE");
|
||||
}
|
||||
|
||||
|
||||
static LRESULT CALLBACK property_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if (message == WM_GETTEXT || message == WM_GETTEXTLENGTH) stall_provider();
|
||||
return CallWindowProcW(native_provider_proc, window, message, wp, lp);
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if (message == WM_GETOBJECT && (LONG)lp == OBJID_CLIENT && !editor_mode && !stalled) {
|
||||
if (getter_mode) return DefWindowProcW(window, message, wp, lp);
|
||||
stall_provider();
|
||||
}
|
||||
if (message == WM_TIMER) {
|
||||
if (!editor_mode) {
|
||||
KillTimer(window, 1);
|
||||
if (getter_mode) {
|
||||
SetFocus(provider);
|
||||
NotifyWinEvent(EVENT_OBJECT_FOCUS, provider, OBJID_CLIENT, CHILDID_SELF);
|
||||
} else NotifyWinEvent(EVENT_OBJECT_FOCUS, window, OBJID_CLIENT, CHILDID_SELF);
|
||||
} else if (++step == 1) {
|
||||
SetForegroundWindow(window); SetFocus(editor);
|
||||
PostMessageW(editor, WM_CHAR, 'a', 0);
|
||||
PostMessageW(editor, WM_CHAR, 'a', 0);
|
||||
} else {
|
||||
KillTimer(window, 1);
|
||||
WCHAR text[16]; DWORD start, end;
|
||||
GetWindowTextW(editor, text, 16);
|
||||
SendMessageW(editor, EM_GETSEL, (WPARAM)&start, (LPARAM)&end);
|
||||
if (lstrcmpW(text, L"aa") || start != 2 || end != 2) ExitProcess(2);
|
||||
marker(L"WINE_ACCESS_EDITOR_CHECKED");
|
||||
SetFocus(button);
|
||||
NotifyWinEvent(EVENT_OBJECT_FOCUS, button, OBJID_CLIENT, CHILDID_SELF);
|
||||
if (popup_mode) {
|
||||
HWND popup = CreateWindowExW(WS_EX_NOACTIVATE, cls_name, L"Owned popup provider",
|
||||
WS_POPUP | WS_VISIBLE, 200, 200, 200, 80, window, NULL, GetModuleHandleW(NULL), NULL);
|
||||
// A bare X server can activate a newly mapped Wine popup despite
|
||||
// WS_EX_NOACTIVATE. Establish the intended owner focus explicitly
|
||||
// before testing its independent accessible focus notification.
|
||||
SetForegroundWindow(window); SetFocus(button);
|
||||
NotifyWinEvent(EVENT_OBJECT_FOCUS, popup, OBJID_CLIENT, CHILDID_SELF);
|
||||
if (GetForegroundWindow() != window || GetFocus() != button) {
|
||||
fprintf(stderr, "popup focus changed: foreground=%p expected=%p focus=%p expected=%p\n",
|
||||
GetForegroundWindow(), window, GetFocus(), button);
|
||||
ExitProcess(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
|
||||
return DefWindowProcW(window, message, wp, lp);
|
||||
}
|
||||
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
|
||||
(void)previous; (void)show;
|
||||
if (strstr(args, "quit-helper")) {
|
||||
HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL);
|
||||
DWORD thread = GetWindowThreadProcessId(bridge, NULL);
|
||||
return thread && PostThreadMessageW(thread, WM_QUIT, 0, 0) ? 0 : 3;
|
||||
}
|
||||
if (FAILED(CoInitializeEx(NULL, COINIT_APARTMENTTHREADED))) return 5;
|
||||
editor_mode = strstr(args, "editor") != NULL;
|
||||
getter_mode = strstr(args, "getter") != NULL;
|
||||
background_mode = strstr(args, "background") != NULL;
|
||||
popup_mode = strstr(args, "popup") != NULL;
|
||||
WNDCLASSW cls = {};
|
||||
cls.hInstance = instance; cls.lpfnWndProc = window_proc; cls.lpszClassName = L"WineEventStallFixture";
|
||||
RegisterClassW(&cls);
|
||||
HWND window = CreateWindowW(cls.lpszClassName,
|
||||
editor_mode ? L"Responsive editor fixture" : L"Stalled provider fixture",
|
||||
WS_OVERLAPPEDWINDOW, 100, 100, 500, 300, NULL, NULL, instance, NULL);
|
||||
if (editor_mode) {
|
||||
editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
|
||||
10, 10, 300, 30, window, NULL, instance, NULL);
|
||||
button = CreateWindowW(L"Button", L"Responsive button", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
|
||||
10, 60, 200, 30, window, NULL, instance, NULL);
|
||||
}
|
||||
if (getter_mode) {
|
||||
provider = CreateWindowW(L"Static", L"Stalled property provider", WS_CHILD | WS_VISIBLE,
|
||||
10, 10, 300, 30, window, NULL, instance, NULL);
|
||||
native_provider_proc = reinterpret_cast<WNDPROC>(SetWindowLongPtrW(provider, GWLP_WNDPROC,
|
||||
reinterpret_cast<LONG_PTR>(property_proc)));
|
||||
}
|
||||
WNDCLASSW popupClass = cls;
|
||||
popupClass.lpszClassName = cls_name; popupClass.lpfnWndProc = DefWindowProcW;
|
||||
RegisterClassW(&popupClass);
|
||||
ShowWindow(window, background_mode ? SW_SHOWNOACTIVATE : SW_SHOW);
|
||||
if (!background_mode) SetForegroundWindow(window);
|
||||
if (editor_mode) SetFocus(editor);
|
||||
SetTimer(window, 1, 500, NULL);
|
||||
MSG message;
|
||||
while (GetMessageW(&message, NULL, 0, 0) > 0) {
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Check event feedback and normal shutdown while a Wine MSAA provider stalls.
|
||||
|
||||
Run on a private X display. Uses a disposable prefix and a recording D-Bus
|
||||
service; no speech reaches the user's reader or physical braille display.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--build-dir", default="build")
|
||||
parser.add_argument("--32-bit", dest="is32Bit", action="store_true")
|
||||
parser.add_argument("--reader", choices=("cthulhu", "orca"), default="cthulhu")
|
||||
parser.add_argument("--session", action="store_true")
|
||||
parser.add_argument("--getter", action="store_true")
|
||||
parser.add_argument("--background", action="store_true")
|
||||
parser.add_argument("--popup", action="store_true")
|
||||
args = parser.parse_args()
|
||||
build = Path(args.build_dir).resolve()
|
||||
if not args.session:
|
||||
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__,
|
||||
"--session", "--build-dir", str(build), "--reader", args.reader,
|
||||
*(["--32-bit"] if args.is32Bit else []),
|
||||
*(["--getter"] if args.getter else []),
|
||||
*(["--background"] if args.background else []),
|
||||
*(["--popup"] if args.popup else [])]))
|
||||
|
||||
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
|
||||
objectPath = "/" + interface.replace(".", "/")
|
||||
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
|
||||
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
|
||||
xml += '<arg type="b" direction="out"/></method></interface></node>'
|
||||
events = []
|
||||
|
||||
|
||||
def record(connection, sender, path, iface, method, parameters, invocation):
|
||||
events.append(parameters.unpack())
|
||||
invocation.return_value(GLib.Variant("(b)", (True,)))
|
||||
|
||||
|
||||
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
|
||||
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
|
||||
"RequestName", GLib.Variant("(su)", (interface, 0)), None,
|
||||
Gio.DBusCallFlags.NONE, 1000, None)
|
||||
|
||||
|
||||
def pump_until(predicate, seconds):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
while GLib.MainContext.default().iteration(False):
|
||||
pass
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(.005)
|
||||
raise AssertionError(f"condition timed out; events: {events}")
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wine-event-stall-") as temporary:
|
||||
root = Path(temporary)
|
||||
env = os.environ.copy()
|
||||
env.update(WINEPREFIX=str(root / "prefix"), WINEDEBUG="-all",
|
||||
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
|
||||
ready, done, checked = root / "ready", root / "done", root / "checked"
|
||||
for key, path in (("WINE_ACCESS_STALL_READY", ready), ("WINE_ACCESS_STALL_DONE", done),
|
||||
("WINE_ACCESS_EDITOR_CHECKED", checked)):
|
||||
env[key] = "Z:" + str(path).replace("/", "\\")
|
||||
fixture = root / "fixture.exe"
|
||||
compiler = "i686-w64-mingw32-g++" if args.is32Bit else "x86_64-w64-mingw32-g++"
|
||||
subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", "-static", "-o", str(fixture),
|
||||
str(Path(__file__).with_name("event_stall_fixture.cpp")), "-luser32", "-loleacc", "-lole32", "-loleaut32", "-luuid"], check=True)
|
||||
children = []
|
||||
try:
|
||||
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
|
||||
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller",
|
||||
"--reader", args.reader], env=env)
|
||||
children.append(helper)
|
||||
time.sleep(1)
|
||||
modes = ["getter"] if args.getter else []
|
||||
if args.background: modes.append("background")
|
||||
if args.background or args.popup:
|
||||
editor = subprocess.Popen(["wine", str(fixture), "editor", *(["popup"] if args.popup else [])], env=env)
|
||||
children.append(editor)
|
||||
if args.popup:
|
||||
pump_until(lambda: editor.poll() is not None or any(e[2] == "Owned popup provider" for e in events), 3)
|
||||
assert editor.poll() is None, f"popup fixture failed: {editor.returncode}"
|
||||
assert any(e[2] == "Owned popup provider" for e in events)
|
||||
print("PASS: accessible owned popup preserves foreground and native focus", flush=True)
|
||||
sys.exit(0)
|
||||
pump_until(lambda: any(e[1] == "text" and e[2] == "a" for e in events), 3)
|
||||
stall = subprocess.Popen(["wine", str(fixture), *modes], env=env)
|
||||
children.append(stall)
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
while GLib.MainContext.default().iteration(False):
|
||||
pass
|
||||
time.sleep(.005)
|
||||
assert not ready.exists(), "background provider was queried"
|
||||
assert any(e[1] == "focus" and e[2] == "Responsive button" and not e[0].endswith(":standard") for e in events), "background event starved foreground MSAA"
|
||||
print("PASS: background provider not queried; ordinary foreground MSAA feedback retained", flush=True)
|
||||
sys.exit(0)
|
||||
stall = subprocess.Popen(["wine", str(fixture), *modes], env=env)
|
||||
children.append(stall)
|
||||
pump_until(ready.exists, 10)
|
||||
events.clear()
|
||||
started = time.monotonic()
|
||||
editor = subprocess.Popen(["wine", str(fixture), "editor"], env=env)
|
||||
children.append(editor)
|
||||
pump_until(lambda: sum(e[1] == "text" and e[2] == "a" for e in events) >= 2, 2)
|
||||
elapsed = time.monotonic() - started
|
||||
assert not done.exists(), "typing waited for the blocked provider"
|
||||
assert helper.poll() is None, "helper exited"
|
||||
print(f"PASS: repeated typing while MSAA stalled ({elapsed:.3f} s)", flush=True)
|
||||
pump_until(lambda: any(e[1] == "focus" and e[2] == "Responsive button" for e in events), 2)
|
||||
assert checked.exists(), "native text, caret or selection changed"
|
||||
assert not done.exists(), "native button feedback waited for the blocked provider"
|
||||
print("PASS: native button fallback; unchanged native text/caret/selection", flush=True)
|
||||
pump_until(done.exists, 10)
|
||||
# Drain enough time for an incorrectly queued late result to surface.
|
||||
deadline = time.monotonic() + .5
|
||||
while time.monotonic() < deadline:
|
||||
while GLib.MainContext.default().iteration(False):
|
||||
pass
|
||||
time.sleep(.005)
|
||||
assert not any(e[9] == "Stalled provider fixture" for e in events), "late stale focus escaped"
|
||||
print("PASS: stale MSAA focus suppressed", flush=True)
|
||||
ready.unlink(); done.unlink()
|
||||
second = subprocess.Popen(["wine", str(fixture), *modes], env=env)
|
||||
children.append(second)
|
||||
pump_until(ready.exists, 10)
|
||||
started = time.monotonic()
|
||||
subprocess.run(["wine", str(fixture), "quit-helper"], env=env, check=True, timeout=3)
|
||||
pump_until(lambda: helper.poll() is not None, 2)
|
||||
assert helper.returncode == 0, f"normal helper shutdown failed: {helper.returncode}"
|
||||
assert not done.exists(), "normal shutdown waited for the provider"
|
||||
print(f"PASS: normal shutdown during MSAA stall ({time.monotonic() - started:.3f} s)", flush=True)
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill(); child.wait(timeout=3)
|
||||
subprocess.run(["wineserver", "-k"], env=env, timeout=5)
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include <windows.h>
|
||||
#include "../menu_bridge.h"
|
||||
|
||||
static HWND editor;
|
||||
static BOOL typed;
|
||||
|
||||
static BOOL marker_exists(const WCHAR *variable) {
|
||||
WCHAR path[4096];
|
||||
return GetEnvironmentVariableW(variable, path, 4096) &&
|
||||
GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
static void marker(const WCHAR *variable) {
|
||||
WCHAR path[4096];
|
||||
if (!GetEnvironmentVariableW(variable, path, 4096)) return;
|
||||
HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
if (file != INVALID_HANDLE_VALUE) CloseHandle(file);
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if (message == WM_TIMER && !typed && marker_exists(L"WINE_ACCESS_READER_WAITING")) {
|
||||
typed = TRUE;
|
||||
const WCHAR *text = L"abbacdef";
|
||||
for (const WCHAR *cursor = text; *cursor; ++cursor)
|
||||
SendMessageW(editor, WM_CHAR, *cursor, 1);
|
||||
WCHAR actual[32];
|
||||
DWORD start = 0, end = 0;
|
||||
GetWindowTextW(editor, actual, 32);
|
||||
SendMessageW(editor, EM_GETSEL, (WPARAM)&start, (LPARAM)&end);
|
||||
if (lstrcmpW(actual, text) || start != 8 || end != 8) ExitProcess(2);
|
||||
marker(L"WINE_ACCESS_READER_TYPED");
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_TIMER && typed && marker_exists(L"WINE_ACCESS_READER_RESUME")) {
|
||||
KillTimer(window, 1);
|
||||
SendMessageW(editor, WM_CHAR, 'z', 1);
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
|
||||
return DefWindowProcW(window, message, wp, lp);
|
||||
}
|
||||
|
||||
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
|
||||
(void)previous; (void)show;
|
||||
if (lstrcmpA(args, "quit-helper") == 0) {
|
||||
DWORD thread = GetWindowThreadProcessId(FindWindowW(WINE_ACCESS_MENU_CLASS, NULL), NULL);
|
||||
return thread && PostThreadMessageW(thread, WM_QUIT, 0, 0) ? 0 : 3;
|
||||
}
|
||||
WNDCLASSW cls = {0};
|
||||
cls.hInstance = instance; cls.lpfnWndProc = window_proc;
|
||||
cls.lpszClassName = L"WineReaderStallFixture";
|
||||
RegisterClassW(&cls);
|
||||
HWND window = CreateWindowW(cls.lpszClassName, L"Reader stall fixture",
|
||||
WS_OVERLAPPEDWINDOW, 100, 100, 500, 300, NULL, NULL, instance, NULL);
|
||||
editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
|
||||
10, 10, 300, 30, window, NULL, instance, NULL);
|
||||
ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor);
|
||||
SetTimer(window, 1, 10, NULL);
|
||||
MSG message;
|
||||
while (GetMessageW(&message, NULL, 0, 0) > 0) {
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Check ordered editor feedback while the reader delays or loses replies.
|
||||
|
||||
Uses a private D-Bus service, disposable Wine prefix and private X display.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--build-dir", default="build")
|
||||
parser.add_argument("--32-bit", dest="is32Bit", action="store_true")
|
||||
parser.add_argument("--timeout", action="store_true")
|
||||
parser.add_argument("--focus-error", action="store_true")
|
||||
parser.add_argument("--session", action="store_true")
|
||||
args = parser.parse_args()
|
||||
build = Path(args.build_dir).resolve()
|
||||
if not args.session:
|
||||
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__,
|
||||
"--session", "--build-dir", str(build),
|
||||
*(["--32-bit"] if args.is32Bit else []),
|
||||
*(["--timeout"] if args.timeout else []),
|
||||
*(["--focus-error"] if args.focus_error else [])]))
|
||||
|
||||
interface = "org.stormux.Cthulhu1.Service"
|
||||
object_path = "/org/stormux/Cthulhu1/Service"
|
||||
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
|
||||
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
|
||||
xml += '<arg type="b" direction="out"/></method></interface></node>'
|
||||
events, held = [], []
|
||||
|
||||
|
||||
def pump_until(predicate, seconds):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
while GLib.MainContext.default().iteration(False):
|
||||
pass
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(.005)
|
||||
raise AssertionError(f"condition timed out; events: {events}")
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wine-reader-stall-") as temporary:
|
||||
root = Path(temporary)
|
||||
waiting, typed, resume = (root / name for name in ("waiting", "typed", "resume"))
|
||||
env = os.environ.copy()
|
||||
env.update(WINEPREFIX=str(root / "prefix"), WINEDEBUG="-all",
|
||||
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
|
||||
for key, path in (("WAITING", waiting), ("TYPED", typed), ("RESUME", resume)):
|
||||
env["WINE_ACCESS_READER_" + key] = "Z:" + str(path).replace("/", "\\")
|
||||
|
||||
def record(connection, sender, path, iface, method, parameters, invocation):
|
||||
event = parameters.unpack()
|
||||
first_focus = not waiting.exists() and event[1] == "focus" and event[0].endswith(":text")
|
||||
if first_focus and args.focus_error:
|
||||
waiting.touch()
|
||||
invocation.return_dbus_error("org.freedesktop.DBus.Error.Failed", "Reader temporarily unavailable")
|
||||
return
|
||||
events.append(event)
|
||||
if first_focus:
|
||||
held.append(invocation)
|
||||
waiting.touch()
|
||||
elif event[1] == "text" and event[2] == "z":
|
||||
held.append(invocation)
|
||||
else:
|
||||
invocation.return_value(GLib.Variant("(b)", (True,)))
|
||||
|
||||
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
connection.register_object(object_path, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
|
||||
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
|
||||
"RequestName", GLib.Variant("(su)", (interface, 0)), None,
|
||||
Gio.DBusCallFlags.NONE, 1000, None)
|
||||
fixture = root / "fixture.exe"
|
||||
compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc"
|
||||
subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", "-o", str(fixture),
|
||||
str(Path(__file__).with_name("reader_stall_fixture.c")), "-luser32"], check=True)
|
||||
children = []
|
||||
try:
|
||||
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
|
||||
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
|
||||
children.append(helper)
|
||||
time.sleep(1)
|
||||
app = subprocess.Popen(["wine", str(fixture)], env=env)
|
||||
children.append(app)
|
||||
pump_until(waiting.exists, 5)
|
||||
# Longer than the hook's 50 ms deadline, shorter than the bus timeout.
|
||||
if not args.focus_error:
|
||||
GLib.timeout_add(700 if args.timeout else 350,
|
||||
lambda: (held.pop(0).return_value(GLib.Variant("(b)", (True,))), False)[1])
|
||||
pump_until(typed.exists, 3)
|
||||
pump_until(lambda: len([e for e in events if e[1] == "text"]) >= 8, 3)
|
||||
actions = [e[2] for e in events if e[1] == "text"]
|
||||
assert actions == list("abbacdef"), f"editor feedback lost or reordered: {actions}"
|
||||
focused = None
|
||||
for event in events:
|
||||
if event[1] == "focus":
|
||||
focused = event[0]
|
||||
elif event[1] == "text":
|
||||
assert event[0] == focused, "text forwarded before its focus"
|
||||
print("PASS: delayed reader preserves repeated typing, order, native text and caret", flush=True)
|
||||
pump_until(lambda: not held, 2)
|
||||
resume.touch()
|
||||
pump_until(lambda: bool(held), 2)
|
||||
started = time.monotonic()
|
||||
quit_client = subprocess.Popen(["wine", str(fixture), "quit-helper"], env=env)
|
||||
children.append(quit_client)
|
||||
pump_until(lambda: helper.poll() is not None, .4)
|
||||
assert helper.returncode == 0, f"helper shutdown failed: {helper.returncode}"
|
||||
print(f"PASS: normal shutdown while reader withholds reply ({time.monotonic() - started:.3f} s)", flush=True)
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill(); child.wait(timeout=3)
|
||||
subprocess.run(["wineserver", "-k"], env=env, timeout=5, check=False)
|
||||
Reference in new issue
Block a user