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
+984
-67
No files matched your search
+1
-1
@@ -51,7 +51,7 @@ add_custom_command(
|
|||||||
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
|
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
|
||||||
VERBATIM)
|
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)
|
add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32)
|
||||||
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
|
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
|
||||||
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
|
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
|
protected values are excluded. Readers should filter name/state changes to
|
||||||
their focused source and distinguish these labels from editor diagnostics.
|
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,
|
Standard `SysListView32` controls now report the current row's text columns,
|
||||||
selection and position, including 32-bit installers driven by the 64-bit
|
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`,
|
helper. Wine can expose these controls only as a generic client named `List1`,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
#include "review_service.h"
|
#include "review_service.h"
|
||||||
#include "foreground_focus.h"
|
#include "foreground_focus.h"
|
||||||
#include "reader_endpoint.h"
|
#include "reader_endpoint.h"
|
||||||
|
#include "msaa_events.h"
|
||||||
|
#include "reader_events.h"
|
||||||
|
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <gio/gio.h>
|
#include <gio/gio.h>
|
||||||
@@ -33,6 +35,10 @@ constexpr int DBUS_TIMEOUT_MS = 500;
|
|||||||
GDBusConnection *connection = nullptr;
|
GDBusConnection *connection = nullptr;
|
||||||
ReviewIdentityStore reviewIdentities;
|
ReviewIdentityStore reviewIdentities;
|
||||||
ReviewService reviewService;
|
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<WineAccessMenuEvent> menuEvents;
|
||||||
std::deque<WineAccessTextEvent> textEvents;
|
std::deque<WineAccessTextEvent> textEvents;
|
||||||
std::deque<WineAccessNotificationEvent> notificationEvents;
|
std::deque<WineAccessNotificationEvent> notificationEvents;
|
||||||
@@ -156,31 +162,6 @@ bool reader_is_available() {
|
|||||||
return owned == TRUE;
|
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
|
// Standard-control queries follow the LGPL wine-a11y reader published by
|
||||||
// Mudb0y, while presentation uses the selected reader's D-Bus interface.
|
// Mudb0y, while presentation uses the selected reader's D-Bus interface.
|
||||||
void strip_mnemonics(wchar_t *text) {
|
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.
|
// Readers can update their focus owner even when they suppress its output.
|
||||||
if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text"))
|
if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text"))
|
||||||
lastTextControl = nullptr;
|
lastTextControl = nullptr;
|
||||||
return call_boolean("PresentAccessibleEvent",
|
return readerEvents.submit(g_variant_new(
|
||||||
g_variant_new(
|
|
||||||
"(sssssssiis)", sourceId.c_str(), eventType,
|
"(sssssssiis)", sourceId.c_str(), eventType,
|
||||||
control.name.c_str(), control.role.c_str(),
|
control.name.c_str(), control.role.c_str(),
|
||||||
control.value.c_str(), control.states.c_str(),
|
control.value.c_str(), control.states.c_str(),
|
||||||
control.description.c_str(), control.position,
|
control.description.c_str(), control.position,
|
||||||
control.count, windowTitle.c_str())) == ERROR_SUCCESS;
|
control.count, windowTitle.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
ControlDescription describe_standard_control(HWND window) {
|
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,
|
void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||||
LONG objectId, LONG childId, DWORD, DWORD) {
|
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);
|
if (event == EVENT_OBJECT_DESTROY) reviewIdentities.destroyed(window,objectId,childId);
|
||||||
else if (event == EVENT_OBJECT_REORDER) reviewIdentities.reordered(window,objectId);
|
else if (event == EVENT_OBJECT_REORDER) reviewIdentities.reordered(window,objectId);
|
||||||
if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND)
|
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);
|
else poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
IAccessible *accessible = nullptr;
|
DWORD process = 0;
|
||||||
VARIANT child{};
|
DWORD thread = GetWindowThreadProcessId(window, &process);
|
||||||
VariantInit(&child);
|
MsaaEvent request{window, event, process, thread, objectId, childId,
|
||||||
if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible,
|
msaaGeneration, GetTickCount64() + 500};
|
||||||
&child)) ||
|
if (event == EVENT_OBJECT_FOCUS) pendingMsaaFocus = request;
|
||||||
accessible == nullptr) {
|
msaaEvents.submit(request);
|
||||||
if (event == EVENT_OBJECT_FOCUS)
|
}
|
||||||
present_standard_focus(window);
|
|
||||||
|
bool current_msaa_source(const MsaaEvent &source) {
|
||||||
|
DWORD process = 0;
|
||||||
|
DWORD thread = GetWindowThreadProcessId(source.window, &process);
|
||||||
|
return source.generation == msaaGeneration && IsWindow(source.window) &&
|
||||||
|
process == source.process && thread == source.thread &&
|
||||||
|
msaa_event_in_foreground(source.window);
|
||||||
|
}
|
||||||
|
|
||||||
|
void present_msaa_result(const MsaaResult &result) {
|
||||||
|
const auto &source = result.source;
|
||||||
|
if (!current_msaa_source(source) || GetTickCount64() >= source.deadline) return;
|
||||||
|
if (source.event == EVENT_OBJECT_FOCUS && pendingMsaaFocus.generation == source.generation)
|
||||||
|
pendingMsaaFocus = {};
|
||||||
|
HWND window = source.window;
|
||||||
|
DWORD event = source.event;
|
||||||
|
LONG objectId = source.object, childId = source.child;
|
||||||
|
if (!result.available) {
|
||||||
|
if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const auto &name = result.name, &value = result.value, &description = result.description;
|
||||||
const std::string name =
|
const auto &roleText = result.role, &stateText = result.states;
|
||||||
accessible_property(accessible, &IAccessible::get_accName, child);
|
bool password = result.password, nativeClient = result.native_client;
|
||||||
const std::string value =
|
LONG childCount = result.count;
|
||||||
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;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() &&
|
if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() &&
|
||||||
description.empty() && (roleText.empty() || roleText == "client") &&
|
description.empty() && (roleText.empty() || roleText == "client") &&
|
||||||
@@ -1083,7 +1063,17 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HWND window = nullptr;
|
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);
|
reviewService.start(connection,reviewIdentities,readerEndpoint);
|
||||||
|
if (dialogReaderEnabled && !msaaEvents.start()) {
|
||||||
|
std::fprintf(stderr, "wine-access: MSAA event worker unavailable\n");
|
||||||
|
}
|
||||||
bool controllerActive = false;
|
bool controllerActive = false;
|
||||||
if (controllerEnabled &&
|
if (controllerEnabled &&
|
||||||
FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) {
|
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) {
|
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||||
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
|
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
|
||||||
message.wParam == listTimer) {
|
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();
|
restore_virtual_desktop_focus();
|
||||||
poll_control_focus();
|
poll_control_focus();
|
||||||
reviewService.focus_changed(reviewIdentities.observe_foreground());
|
reviewService.focus_changed(reviewIdentities.observe_foreground());
|
||||||
@@ -1157,6 +1155,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
|||||||
DispatchMessageW(&message);
|
DispatchMessageW(&message);
|
||||||
}
|
}
|
||||||
if (listTimer) KillTimer(nullptr, listTimer);
|
if (listTimer) KillTimer(nullptr, listTimer);
|
||||||
|
readerEvents.stop();
|
||||||
|
msaaEvents.stop();
|
||||||
reviewService.stop();
|
reviewService.stop();
|
||||||
if (hook != nullptr)
|
if (hook != nullptr)
|
||||||
UnhookWinEvent(hook);
|
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
|
# Wine control integration
|
||||||
|
|
||||||
|
## UIA notifications
|
||||||
|
|
||||||
UIA notification routing uses the actual native application hooks and a private
|
UIA notification routing uses the actual native application hooks and a private
|
||||||
recording reader, without sending speech to the live reader:
|
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
|
including switching away and back. Also check existing NVDA/Tolk speech and
|
||||||
ordinary Wine menus and Edit controls before submitting the change.
|
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
|
## Reader endpoint selection
|
||||||
|
|
||||||
After building, run these checks sequentially on private displays:
|
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