More fixes discovered based on Deedra's work.
This commit is contained in:
1 parent
de31e5b513
commit
17052c5147
22 files changed
+561
-76
No files matched your search
+8
-2
@@ -99,6 +99,11 @@ if(BUILD_TESTING)
|
||||
target_compile_features(msaa-states-test PRIVATE cxx_std_20)
|
||||
target_compile_options(msaa-states-test PRIVATE -Wall -Wextra)
|
||||
add_wine_test(msaa-states 35 "${CMAKE_CURRENT_BINARY_DIR}/msaa-states-test.exe")
|
||||
add_executable(text-limits-test tests/text_limits_test.cpp reader_events.cpp)
|
||||
target_compile_features(text-limits-test PRIVATE cxx_std_20)
|
||||
target_compile_options(text-limits-test PRIVATE -Wall -Wextra)
|
||||
target_link_libraries(text-limits-test PRIVATE PkgConfig::GIO user32)
|
||||
add_wine_test(text-limits 35 dbus-run-session -- "${CMAKE_CURRENT_BINARY_DIR}/text-limits-test.exe")
|
||||
add_executable(review-capture-test tests/review_capture_test.cpp)
|
||||
target_compile_features(review-capture-test PRIVATE cxx_std_20)
|
||||
target_compile_options(review-capture-test PRIVATE -Wall -Wextra)
|
||||
@@ -120,14 +125,15 @@ if(BUILD_TESTING)
|
||||
add_custom_target(hook-safety-fixture${arch} ALL DEPENDS "${hook_fixture}")
|
||||
set(review_fixture "${CMAKE_CURRENT_BINARY_DIR}/review-fixture${arch}.exe")
|
||||
add_custom_command(OUTPUT "${review_fixture}"
|
||||
COMMAND "${fixture_compiler}" -std=c11 -Wall -Wextra -mwindows
|
||||
COMMAND "${fixture_compiler}" -std=c11 -Wall -Wextra -mwindows -Wl,--stack,1048576
|
||||
-o "${review_fixture}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/review_fixture.c" -luser32 -lcomctl32
|
||||
DEPENDS tests/review_fixture.c VERBATIM)
|
||||
DEPENDS tests/review_fixture.c edit_hook.h VERBATIM)
|
||||
add_custom_target(review-fixture${arch} ALL DEPENDS "${review_fixture}")
|
||||
add_dependencies(review-capture-test review-fixture${arch})
|
||||
add_wine_test(review-capture${arch} 35
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/review-capture-test.exe" --fixture "${review_fixture}")
|
||||
endforeach()
|
||||
add_dependencies(review-capture-test wine-access-menu wine-access-menu32)
|
||||
add_wine_test(hook-safety 100 python3
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/tests/hook_safety_test.py" "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
endif()
|
||||
|
||||
@@ -34,6 +34,32 @@ imports remain valid across reader restarts. Close and reopen applications after
|
||||
updating Wine Access to load the new hooks. `--no-dialog-reader` disables these
|
||||
hooks along with standard control reading.
|
||||
|
||||
Long-running text processing is bounded at the helper boundary. MSAA properties
|
||||
retain at most 4096 UTF-16 code units each, without splitting a surrogate pair.
|
||||
The reader queue accepts at most 128 events, 128 KiB per serialized event, and
|
||||
1 MiB of serialized pending events. When full it drops new feedback and reports
|
||||
the condition once. NVDA/Tolk speech, braille and SSML requests longer than
|
||||
65536 UTF-16 code units return `ERROR_INVALID_PARAMETER` before UTF-8 conversion.
|
||||
These limits apply per request, not to the lifetime number of processed lines.
|
||||
|
||||
Window review reads large standard Edit controls through a native hook query,
|
||||
which avoids sending their text buffers through Wine's message-hook stack.
|
||||
Review can attach the foreground UI thread's hook to an output editor without
|
||||
moving keyboard focus there.
|
||||
The existing aggregate source limit of one million UTF-16 code units still
|
||||
applies. Editors without the updated hook use a conservative 16384-unit fallback;
|
||||
larger sources report `limited` until an updated hook is attached. Restart both
|
||||
the helper and the affected Wine application after upgrading. Programmatic Edit
|
||||
replacements defer snapshot refresh until an editor action needs it, background
|
||||
updates do not copy the document, and snapshot buffers reuse bounded capacity.
|
||||
|
||||
Native list, tree and tab queries retain target-process buffers until their
|
||||
completion callbacks arrive. A late query prevents additional remote allocations
|
||||
on that reader thread, while ordinary event handling continues. Failed reads are
|
||||
retried instead of being cached as complete. These protections do not establish
|
||||
the cause of a reported whole-machine lockup or bound a reader's downstream
|
||||
speech backlog.
|
||||
|
||||
The executable is named `wine-access.exe`. Its launcher, Winelib sidecar,
|
||||
and native hooks share a reader-independent installation directory.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||
pkgname=wine-access-git
|
||||
pkgver=r58.gdbf3ceb
|
||||
pkgver=r60.gde31e5b
|
||||
pkgrel=1
|
||||
pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for screen readers'
|
||||
arch=('x86_64')
|
||||
|
||||
+75
-15
@@ -19,6 +19,7 @@ typedef struct EditState {
|
||||
BOOL history_key, history_replaced;
|
||||
WCHAR pending_high;
|
||||
DWORD pending_caret;
|
||||
DWORD capacity;
|
||||
} EditState;
|
||||
|
||||
static void selection(HWND window, DWORD *start, DWORD *end) {
|
||||
@@ -37,22 +38,61 @@ static LRESULT query_text(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
}
|
||||
|
||||
static void snapshot(HWND window, EditState *state) {
|
||||
LRESULT length = query_text(window, WM_GETTEXTLENGTH, 0, 0);
|
||||
state->length = length >= 0 && (ULONGLONG)length <= MAXDWORD ? (DWORD)length : 0;
|
||||
if (length < 0 || (ULONGLONG)length > MAX_SNAPSHOT_UNITS) goto discard;
|
||||
if (state->capacity <= state->length) {
|
||||
DWORD capacity = state->length + 1;
|
||||
// Grow geometrically rather than allocating another transcript on every
|
||||
// appended line. Never retain more than the original snapshot budget.
|
||||
if (capacity < 4096) capacity = 4096;
|
||||
if (capacity < state->capacity * 2) capacity = state->capacity * 2;
|
||||
if (capacity > MAX_SNAPSHOT_UNITS + 1) capacity = MAX_SNAPSHOT_UNITS + 1;
|
||||
WCHAR *text = realloc(state->text, (size_t)capacity * sizeof(WCHAR));
|
||||
if (!text) goto discard;
|
||||
state->text = text;
|
||||
state->capacity = capacity;
|
||||
}
|
||||
LRESULT copied = query_text(window, WM_GETTEXT, state->length + 1, (LPARAM)state->text);
|
||||
if (copied < 0 || (ULONGLONG)copied > state->length) goto discard;
|
||||
state->length = copied;
|
||||
state->text[state->length] = 0;
|
||||
return;
|
||||
discard:
|
||||
free(state->text);
|
||||
state->text = NULL;
|
||||
state->length = query_text(window, WM_GETTEXTLENGTH, 0, 0);
|
||||
if (state->length <= MAX_SNAPSHOT_UNITS) {
|
||||
state->text = calloc((size_t)state->length + 1, sizeof(WCHAR));
|
||||
if (state->text) {
|
||||
LRESULT copied = query_text(window, WM_GETTEXT, state->length + 1, (LPARAM)state->text);
|
||||
if (copied >= 0 && (ULONGLONG)copied <= state->length) {
|
||||
state->length = copied;
|
||||
} else {
|
||||
// A failed query must never become a length used to index this buffer.
|
||||
free(state->text);
|
||||
state->text = NULL;
|
||||
}
|
||||
}
|
||||
state->capacity = 0;
|
||||
}
|
||||
|
||||
static LRESULT read_review_text(HWND window, EditState *state, WineAccessEditText *request) {
|
||||
if (!request || !request->capacity || request->capacity > MAX_SNAPSHOT_UNITS + 1 ||
|
||||
(GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD)) return 0;
|
||||
state->busy = TRUE;
|
||||
LRESULT length = query_text(window, WM_GETTEXTLENGTH, 0, 0);
|
||||
LRESULT copied = -1;
|
||||
if (!state->destroyed && !(GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) && length >= 0) {
|
||||
if ((ULONGLONG)length >= request->capacity) request->status = 2;
|
||||
else copied = query_text(window, WM_GETTEXT, request->capacity, (LPARAM)request->text);
|
||||
}
|
||||
if (state->destroyed) {
|
||||
free(state->text); free(state);
|
||||
return 0;
|
||||
}
|
||||
BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
|
||||
if (password) {
|
||||
// A provider can enable protection during the busy text query, bypassing
|
||||
// handle_edit's ordinary password-change cleanup.
|
||||
free(state->text); state->text = NULL; state->capacity = 0;
|
||||
state->known = FALSE; state->pending_high = 0;
|
||||
}
|
||||
if (copied == length && copied >= 0 && (ULONGLONG)copied < request->capacity &&
|
||||
!password) {
|
||||
request->copied = copied;
|
||||
request->text[copied] = 0;
|
||||
request->status = 1;
|
||||
}
|
||||
state->busy = FALSE;
|
||||
return request->status;
|
||||
}
|
||||
|
||||
static void copy_range(WCHAR *output, const WCHAR *text, DWORD start, DWORD end) {
|
||||
@@ -126,6 +166,20 @@ static __attribute__((noinline)) LRESULT handle_edit(HWND window, UINT message,
|
||||
state->history_key = state->history_replaced = FALSE;
|
||||
BOOL history_replacement = message == WM_SETTEXT && state->history_key;
|
||||
|
||||
// Programmatic output updates have no editor action to announce. Rebuild
|
||||
// lazily on navigation/typing/focus, not on every incoming transcript line.
|
||||
// Pretranslated command-history replacements still need immediate feedback.
|
||||
BOOL programmatic = message == EM_REPLACESEL || message == EM_SETHANDLE ||
|
||||
message == EM_FMTLINES || (message == WM_SETTEXT && !history_replacement);
|
||||
if ((!focus && GetFocus() != window) || (changing && programmatic)) {
|
||||
if (GetFocus() != window) {
|
||||
free(state->text); state->text = NULL; state->capacity = 0;
|
||||
}
|
||||
state->known = FALSE;
|
||||
state->pending_high = 0;
|
||||
return DefSubclassProc(window, message, wp, lp);
|
||||
}
|
||||
|
||||
state->busy = TRUE;
|
||||
WCHAR pending_high = state->pending_high;
|
||||
DWORD pending_caret = state->pending_caret;
|
||||
@@ -159,7 +213,7 @@ static __attribute__((noinline)) LRESULT handle_edit(HWND window, UINT message,
|
||||
selection(window, &start, &end);
|
||||
password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
|
||||
if (password) {
|
||||
free(state->text); state->text = NULL; state->known = FALSE;
|
||||
free(state->text); state->text = NULL; state->capacity = 0; state->known = FALSE;
|
||||
if (focus) notify_text(window, WINE_ACCESS_TEXT_PASSWORD, L"", start, end);
|
||||
state->busy = FALSE;
|
||||
return result;
|
||||
@@ -265,11 +319,14 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
||||
if (message == WM_NCDESTROY) {
|
||||
RemoveWindowSubclass(window, edit_proc, id);
|
||||
RemovePropW(window, EDIT_STATE_PROPERTY);
|
||||
RemovePropW(window, EDIT_REVIEW_PROPERTY);
|
||||
state->destroyed = TRUE;
|
||||
if (!state->busy) { free(state->text); free(state); }
|
||||
return DefSubclassProc(window, message, wp, lp);
|
||||
}
|
||||
if (state->busy) return DefSubclassProc(window, message, wp, lp);
|
||||
if (message == RegisterWindowMessageW(EDIT_REVIEW_MESSAGE))
|
||||
return read_review_text(window, state, (WineAccessEditText *)lp);
|
||||
if (message == WM_KILLFOCUS)
|
||||
state->history_key = state->history_replaced = FALSE;
|
||||
BOOL relevant = FALSE;
|
||||
@@ -313,7 +370,10 @@ static BOOL watch_edit(HWND window, EditState **edit_state) {
|
||||
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
|
||||
free(state); return FALSE;
|
||||
}
|
||||
if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) {
|
||||
if (!SetPropW(window, EDIT_STATE_PROPERTY, state) ||
|
||||
!SetPropW(window, EDIT_REVIEW_PROPERTY, (HANDLE)1)) {
|
||||
RemovePropW(window, EDIT_STATE_PROPERTY);
|
||||
RemovePropW(window, EDIT_REVIEW_PROPERTY);
|
||||
RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID);
|
||||
free(state); return FALSE;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
#include <windows.h>
|
||||
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
|
||||
#define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus"
|
||||
#define EDIT_REVIEW_MESSAGE L"CthulhuWineEditReviewTextV1"
|
||||
#define EDIT_REVIEW_PROPERTY L"CthulhuWineEditReviewV1"
|
||||
// Fixed-width layout shared with 32-bit applications. The allocation lives
|
||||
// in the application's address space until the message has really completed.
|
||||
typedef struct WineAccessEditText {
|
||||
DWORD capacity, copied, status; // status: 0 unavailable, 1 complete, 2 limited
|
||||
WCHAR text[1];
|
||||
} WineAccessEditText;
|
||||
BOOL wine_access_watch_edit(HWND window);
|
||||
BOOL wine_access_watch_scintilla(HWND window);
|
||||
void wine_access_scintilla_input(HWND window, const MSG *message);
|
||||
|
||||
+9
-3
@@ -60,8 +60,10 @@ int list_view_index(HWND window, int *count) {
|
||||
ListViewRow read_list_view(HWND window) {
|
||||
ListViewRow row;
|
||||
row.index = list_view_index(window, &row.count);
|
||||
if (row.index < 0 || row.index >= row.count)
|
||||
if (row.index < 0 || row.index >= row.count) {
|
||||
row.available = row.count == 0;
|
||||
return row;
|
||||
}
|
||||
DWORD_PTR state = 0;
|
||||
if (query(window, LVM_GETITEMSTATE, row.index, LVIS_SELECTED, &state))
|
||||
row.selected = state & LVIS_SELECTED;
|
||||
@@ -77,6 +79,7 @@ ListViewRow read_list_view(HWND window) {
|
||||
}
|
||||
// Keep provider work and the protocol payload bounded even for huge tables.
|
||||
if (columnCount > 8) columnCount = 8;
|
||||
bool complete = true;
|
||||
for (DWORD_PTR column = 0; column < columnCount; ++column) {
|
||||
BYTE buffer[bufferSize] = {};
|
||||
const auto textAddress = remote.address(textOffset);
|
||||
@@ -92,9 +95,11 @@ ListViewRow read_list_view(HWND window) {
|
||||
DWORD_PTR result = 0;
|
||||
wchar_t text[textCapacity] = {};
|
||||
if (!remote.write(buffer, sizeof(buffer)) ||
|
||||
!query(window, LVM_GETITEMTEXTW, row.index, static_cast<LPARAM>(remote.address()), &result) ||
|
||||
!remote.read(text, sizeof(text), textOffset))
|
||||
!remote.query(window, LVM_GETITEMTEXTW, row.index, &result) ||
|
||||
!remote.read(text, sizeof(text), textOffset)) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
text[textCapacity - 1] = L'\0';
|
||||
if (!text[0]) continue;
|
||||
char utf8[textCapacity * 4] = {};
|
||||
@@ -103,6 +108,7 @@ ListViewRow read_list_view(HWND window) {
|
||||
if (!row.text.empty()) row.text += ", ";
|
||||
row.text += utf8;
|
||||
}
|
||||
row.available = complete;
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <string>
|
||||
|
||||
struct ListViewRow {
|
||||
bool available = false;
|
||||
std::string text;
|
||||
int index = -1;
|
||||
int count = 0;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "bootstrapper_controls.h"
|
||||
#include "bootstrapper_hook.h"
|
||||
#include "version.h"
|
||||
#include "text_limits.h"
|
||||
|
||||
#include <commctrl.h>
|
||||
#include <gio/gio.h>
|
||||
@@ -411,11 +412,13 @@ ControlDescription describe_standard_control(HWND window) {
|
||||
if (!control.states.empty()) control.states += ", ";
|
||||
control.states += row.checked ? "checked" : "not checked";
|
||||
}
|
||||
lastListControl = window;
|
||||
lastListIndex = row.index;
|
||||
lastListCount = row.count;
|
||||
lastListSelected = row.selected;
|
||||
lastListChecked = row.checked;
|
||||
if (row.available) {
|
||||
lastListControl = window;
|
||||
lastListIndex = row.index;
|
||||
lastListCount = row.count;
|
||||
lastListSelected = row.selected;
|
||||
lastListChecked = row.checked;
|
||||
} else lastListControl = nullptr; // Retry after a late native reply.
|
||||
return control;
|
||||
}
|
||||
|
||||
@@ -426,8 +429,10 @@ ControlDescription describe_standard_control(HWND window) {
|
||||
control.states = item.states;
|
||||
control.position = item.position;
|
||||
control.count = item.count;
|
||||
lastNativeControl = window;
|
||||
lastNativeItem = item.key;
|
||||
if (item.available) {
|
||||
lastNativeControl = window;
|
||||
lastNativeItem = item.key;
|
||||
} else lastNativeControl = nullptr;
|
||||
return control;
|
||||
}
|
||||
|
||||
@@ -1067,6 +1072,7 @@ extern "C" error_status_t __stdcall testIfRunning(void) {
|
||||
return reader_is_available() ? ERROR_SUCCESS : ERROR_SERVICE_NOT_ACTIVE;
|
||||
}
|
||||
extern "C" error_status_t __stdcall speakText(const wchar_t *text) {
|
||||
if (!controller_text_fits(text)) return ERROR_INVALID_PARAMETER;
|
||||
const std::string utf8 = to_utf8(text);
|
||||
// Tolk calls cancelSpeech separately when its interrupt argument is true.
|
||||
return call_boolean("SpeakText", g_variant_new("(sb)", utf8.c_str(), FALSE));
|
||||
@@ -1075,6 +1081,7 @@ extern "C" error_status_t __stdcall cancelSpeech(void) {
|
||||
return call_boolean("CancelSpeech", nullptr);
|
||||
}
|
||||
extern "C" error_status_t __stdcall brailleMessage(const wchar_t *text) {
|
||||
if (!controller_text_fits(text)) return ERROR_INVALID_PARAMETER;
|
||||
const std::string utf8 = to_utf8(text);
|
||||
return call_boolean("BrailleMessage", g_variant_new("(s)", utf8.c_str()));
|
||||
}
|
||||
@@ -1086,6 +1093,7 @@ extern "C" error_status_t __stdcall getProcessId(ULONG *pid) {
|
||||
}
|
||||
extern "C" error_status_t __stdcall speakSsml(const wchar_t *ssml, SYMBOL_LEVEL,
|
||||
SPEECH_PRIORITY, boolean) {
|
||||
if (!controller_text_fits(ssml)) return ERROR_INVALID_PARAMETER;
|
||||
const std::string utf8 = to_utf8(ssml);
|
||||
GError *error = nullptr;
|
||||
GVariant *reply = g_dbus_connection_call_sync(
|
||||
|
||||
+3
-1
@@ -69,7 +69,9 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp,
|
||||
if (code >= 0) {
|
||||
wine_access_watch_notifications();
|
||||
const CWPSTRUCT *message = (const CWPSTRUCT *)lp;
|
||||
if (message->message == WM_SETFOCUS) wine_access_watch_edit(message->hwnd);
|
||||
if (message->message == WM_SETFOCUS ||
|
||||
(message->message >= 0xc000 && message->message == RegisterWindowMessageW(EDIT_REVIEW_MESSAGE)))
|
||||
wine_access_watch_edit(message->hwnd);
|
||||
if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP)
|
||||
handle_menu(message);
|
||||
}
|
||||
|
||||
+5
-9
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "msaa_events.h"
|
||||
#include "msaa_states.h"
|
||||
#include "text_limits.h"
|
||||
#include <oleacc.h>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
@@ -13,19 +14,13 @@ namespace {
|
||||
// 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;
|
||||
return bounded_accessible_text(value, max_accessible_units);
|
||||
}
|
||||
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{};
|
||||
auto result = SUCCEEDED(hr) ? bounded_accessible_text(value, value ? SysStringLen(value) : 0) : std::string{};
|
||||
SysFreeString(value);
|
||||
return result;
|
||||
}
|
||||
@@ -79,7 +74,8 @@ struct MsaaEventReader::State {
|
||||
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);
|
||||
} else if (role.vt == VT_BSTR)
|
||||
result.role = bounded_accessible_text(role.bstrVal, role.bstrVal ? SysStringLen(role.bstrVal) : 0);
|
||||
}
|
||||
VariantClear(&role);
|
||||
if (!current(event)) return result;
|
||||
|
||||
+4
-3
@@ -72,7 +72,7 @@ NativeItem read_native_item(HWND window) {
|
||||
item.role = tab ? "page tab" : "tree";
|
||||
const auto key = native_item_key(window);
|
||||
item.key = key;
|
||||
if (!key.item) return item;
|
||||
if (!key.item) { item.available = true; return item; }
|
||||
native_control::RemoteBuffer remote(window, bufferSize);
|
||||
if (!remote) return item;
|
||||
const bool wow64 = remote.is32Bit();
|
||||
@@ -95,9 +95,10 @@ NativeItem read_native_item(HWND window) {
|
||||
}
|
||||
DWORD_PTR result = 0;
|
||||
if (remote.write(buffer, sizeof(buffer)) &&
|
||||
query(window, tab ? TCM_GETITEMW : TVM_GETITEMW, tab ? key.item - 1 : 0,
|
||||
static_cast<LPARAM>(remote.address()), &result) && result &&
|
||||
remote.query(window, tab ? TCM_GETITEMW : TVM_GETITEMW, tab ? key.item - 1 : 0,
|
||||
&result) && result &&
|
||||
remote.read(buffer, sizeof(buffer))) {
|
||||
item.available = true;
|
||||
wchar_t text[textCapacity] = {};
|
||||
memcpy(text, buffer + textOffset, sizeof(text));
|
||||
text[textCapacity - 1] = L'\0';
|
||||
|
||||
@@ -11,6 +11,7 @@ struct NativeItemKey {
|
||||
bool operator==(const NativeItemKey &) const = default;
|
||||
};
|
||||
struct NativeItem {
|
||||
bool available = false;
|
||||
NativeItemKey key;
|
||||
std::string text;
|
||||
std::string role;
|
||||
|
||||
+59
-18
@@ -3,6 +3,7 @@
|
||||
#include <windows.h>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
namespace native_control {
|
||||
inline bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *result) {
|
||||
@@ -13,46 +14,86 @@ inline bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *re
|
||||
// Common-control messages above WM_USER do not marshal embedded pointers.
|
||||
// Own the target allocation and process handle together, including failures.
|
||||
class RemoteBuffer {
|
||||
struct Allocation {
|
||||
HANDLE process = nullptr;
|
||||
void *memory = nullptr;
|
||||
BOOL wow64 = FALSE;
|
||||
bool pending = false;
|
||||
LRESULT result = 0;
|
||||
~Allocation() {
|
||||
if (memory) VirtualFreeEx(process, memory, 0, MEM_RELEASE);
|
||||
if (process) CloseHandle(process);
|
||||
}
|
||||
};
|
||||
inline static thread_local std::weak_ptr<Allocation> outstanding_;
|
||||
static void CALLBACK completed(HWND, UINT, ULONG_PTR data, LRESULT result) {
|
||||
std::unique_ptr<std::shared_ptr<Allocation>> retained(
|
||||
reinterpret_cast<std::shared_ptr<Allocation> *>(data));
|
||||
(*retained)->result = result;
|
||||
(*retained)->pending = false;
|
||||
}
|
||||
public:
|
||||
RemoteBuffer(HWND window, size_t size) {
|
||||
// One late application request may retain its allocation. Do not create
|
||||
// another on every polling tick while that application is unresponsive.
|
||||
if (auto previous = outstanding_.lock(); previous && previous->pending) return;
|
||||
allocation_ = std::make_shared<Allocation>();
|
||||
DWORD processId = 0;
|
||||
GetWindowThreadProcessId(window, &processId);
|
||||
process_ = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ |
|
||||
allocation_->process = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ |
|
||||
PROCESS_VM_WRITE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
FALSE, processId);
|
||||
if (!process_ || !IsWow64Process(process_, &wow64_)) return;
|
||||
memory_ = VirtualAllocEx(process_, nullptr, size,
|
||||
if (!allocation_->process || !IsWow64Process(allocation_->process, &allocation_->wow64)) return;
|
||||
allocation_->memory = VirtualAllocEx(allocation_->process, nullptr, size,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||
// Every byte of the allocation must be addressable by the target.
|
||||
if (memory_ && wow64_ &&
|
||||
if (allocation_->memory && allocation_->wow64 &&
|
||||
(address() > UINT32_MAX || size - 1 > UINT32_MAX - address())) {
|
||||
VirtualFreeEx(process_, memory_, 0, MEM_RELEASE);
|
||||
memory_ = nullptr;
|
||||
VirtualFreeEx(allocation_->process, allocation_->memory, 0, MEM_RELEASE);
|
||||
allocation_->memory = nullptr;
|
||||
}
|
||||
}
|
||||
~RemoteBuffer() {
|
||||
if (memory_) VirtualFreeEx(process_, memory_, 0, MEM_RELEASE);
|
||||
if (process_) CloseHandle(process_);
|
||||
}
|
||||
RemoteBuffer(const RemoteBuffer &) = delete;
|
||||
RemoteBuffer &operator=(const RemoteBuffer &) = delete;
|
||||
|
||||
explicit operator bool() const { return memory_ != nullptr; }
|
||||
bool is32Bit() const { return wow64_ != FALSE; }
|
||||
explicit operator bool() const { return allocation_ && allocation_->memory; }
|
||||
bool is32Bit() const { return allocation_->wow64 != FALSE; }
|
||||
uintptr_t address(size_t offset = 0) const {
|
||||
return reinterpret_cast<uintptr_t>(memory_) + offset;
|
||||
return reinterpret_cast<uintptr_t>(allocation_->memory) + offset;
|
||||
}
|
||||
bool write(const void *buffer, size_t size) const {
|
||||
return WriteProcessMemory(process_, memory_, buffer, size, nullptr) != 0;
|
||||
return !allocation_->pending && WriteProcessMemory(allocation_->process, allocation_->memory, buffer, size, nullptr) != 0;
|
||||
}
|
||||
bool read(void *buffer, size_t size, size_t offset = 0) const {
|
||||
return ReadProcessMemory(process_, reinterpret_cast<const void *>(address(offset)),
|
||||
return !allocation_->pending && ReadProcessMemory(allocation_->process, reinterpret_cast<const void *>(address(offset)),
|
||||
buffer, size, nullptr) != 0;
|
||||
}
|
||||
|
||||
bool query(HWND window, UINT message, WPARAM wp, DWORD_PTR *result) {
|
||||
if (!*this || allocation_->pending) return false;
|
||||
allocation_->pending = true;
|
||||
outstanding_ = allocation_;
|
||||
auto retained = new std::shared_ptr<Allocation>(allocation_);
|
||||
if (!SendMessageCallbackW(window, message, wp, static_cast<LPARAM>(address()),
|
||||
completed, reinterpret_cast<ULONG_PTR>(retained))) {
|
||||
allocation_->pending = false;
|
||||
delete retained;
|
||||
return false;
|
||||
}
|
||||
const ULONGLONG deadline = GetTickCount64() + 100;
|
||||
while (allocation_->pending && GetTickCount64() < deadline) {
|
||||
// Dispatch sent-message completions without consuming queued UI work.
|
||||
// Reentrant polls cannot allocate while outstanding_ remains pending.
|
||||
MSG message{};
|
||||
PeekMessageW(&message, nullptr, 0, 0, PM_NOREMOVE);
|
||||
if (allocation_->pending) Sleep(1);
|
||||
}
|
||||
if (allocation_->pending) return false;
|
||||
*result = static_cast<DWORD_PTR>(allocation_->result);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
HANDLE process_ = nullptr;
|
||||
void *memory_ = nullptr;
|
||||
BOOL wow64_ = FALSE;
|
||||
std::shared_ptr<Allocation> allocation_;
|
||||
};
|
||||
} // namespace native_control
|
||||
+9
-2
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "reader_events.h"
|
||||
#include "text_limits.h"
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <deque>
|
||||
@@ -21,11 +22,13 @@ struct ReaderEventQueue::State {
|
||||
std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
std::deque<GVariant *> pending;
|
||||
size_t pendingBytes = 0;
|
||||
bool stopping = false, overflowReported = false;
|
||||
|
||||
void clear() {
|
||||
for (auto *parameters : pending) g_variant_unref(parameters);
|
||||
pending.clear();
|
||||
pendingBytes = 0;
|
||||
}
|
||||
bool send(GVariant *parameters, unsigned &failures) {
|
||||
GError *error = nullptr;
|
||||
@@ -64,6 +67,7 @@ struct ReaderEventQueue::State {
|
||||
if (state->stopping) break;
|
||||
parameters = state->pending.front();
|
||||
state->pending.pop_front();
|
||||
state->pendingBytes -= g_variant_get_size(parameters);
|
||||
}
|
||||
const char *source = nullptr, *kind = nullptr;
|
||||
g_variant_get_child(parameters, 0, "&s", &source);
|
||||
@@ -100,17 +104,20 @@ bool ReaderEventQueue::start(GDBusConnection *connection, const ReaderEndpoint &
|
||||
|
||||
bool ReaderEventQueue::submit(GVariant *parameters) {
|
||||
parameters = g_variant_ref_sink(parameters);
|
||||
const size_t bytes = g_variant_get_size(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) {
|
||||
if (!state_->stopping && state_->pending.size() < 128 &&
|
||||
bytes <= max_reader_event_bytes && bytes <= max_reader_queue_bytes - state_->pendingBytes) {
|
||||
state_->pending.push_back(parameters);
|
||||
state_->pendingBytes += bytes;
|
||||
state_->changed.notify_one();
|
||||
return true;
|
||||
}
|
||||
if (!state_->stopping && !state_->overflowReported) {
|
||||
std::fprintf(stderr, "wine-access: reader event queue full; feedback dropped\n");
|
||||
std::fprintf(stderr, "wine-access: reader event size or queue limit reached; feedback dropped\n");
|
||||
state_->overflowReported = true;
|
||||
}
|
||||
}
|
||||
|
||||
+60
-11
@@ -4,6 +4,7 @@
|
||||
#include "native_query.h"
|
||||
#include "review_x11.h"
|
||||
#include "review_query.h"
|
||||
#include "edit_hook.h"
|
||||
#include <commctrl.h>
|
||||
#include <cstring>
|
||||
#include <oleacc.h>
|
||||
@@ -21,6 +22,7 @@ constexpr DWORD wine_virtual_desktop = 0x80000000;
|
||||
// Wine's import libraries do not export this IID as a Unix data symbol.
|
||||
constexpr GUID accessible_iid{0x618736e0,0x3c3d,0x11cf,{0x81,0x0c,0x00,0xaa,0x00,0x38,0x9b,0x71}};
|
||||
constexpr size_t max_source = 1000000, max_text = 1048576, max_nodes = 4096;
|
||||
constexpr size_t max_unhooked_text = 16384;
|
||||
std::string utf8(const WCHAR *text, size_t length) {
|
||||
if (!length) return {};
|
||||
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text,
|
||||
@@ -56,8 +58,46 @@ bool remote_query(HWND window, UINT message, WPARAM wp, LPARAM lp,
|
||||
return GetTickCount64() < deadline;
|
||||
}
|
||||
bool password(HWND window);
|
||||
bool read_hooked_edit(HWND window, uint64_t deadline, size_t limit,
|
||||
std::vector<WCHAR> &text, bool &limited) {
|
||||
DWORD_PTR answer = 0;
|
||||
const UINT message = RegisterWindowMessageW(EDIT_REVIEW_MESSAGE);
|
||||
// The foreground thread's message hook can attach an output editor that
|
||||
// has never received keyboard focus. First send a scalar attachment probe;
|
||||
// the following query then enters the newly installed subclass reliably.
|
||||
if (!GetPropW(window, EDIT_REVIEW_PROPERTY) &&
|
||||
(!query(window, message, 0, 0, deadline, answer) ||
|
||||
!GetPropW(window, EDIT_REVIEW_PROPERTY))) return false;
|
||||
const size_t offset = offsetof(WineAccessEditText, text);
|
||||
const size_t bytes = offset + (limit + 1) * sizeof(WCHAR);
|
||||
native_control::RemoteBuffer remote(window, bytes);
|
||||
if (!remote) return false;
|
||||
WineAccessEditText request{};
|
||||
request.capacity = limit + 1;
|
||||
if (!remote.write(&request, offset) ||
|
||||
!remote_query(window, message, 0,
|
||||
remote.address(), deadline, answer) ||
|
||||
!remote.read(&request, offset) || password(window)) return false;
|
||||
if (request.status == 2) { limited = true; return false; }
|
||||
if (request.status != 1 || request.copied > limit) return false;
|
||||
text.assign(request.copied + 1, 0);
|
||||
if (!remote.read(text.data(), text.size() * sizeof(WCHAR), offset) || text.back()) return false;
|
||||
text.resize(request.copied);
|
||||
return true;
|
||||
}
|
||||
bool read_text(HWND window, uint64_t deadline, size_t limit, std::vector<WCHAR> &text,
|
||||
bool &limited) {
|
||||
if (password(window)) return false;
|
||||
if (GetPropW(window, EDIT_REVIEW_PROPERTY))
|
||||
return read_hooked_edit(window, deadline, limit, text, limited);
|
||||
WCHAR class_name[128]{};
|
||||
if (GetClassNameW(window, class_name, 128) && wine_access_class_matches(class_name, L"Edit")) {
|
||||
if (read_hooked_edit(window, deadline, limit, text, limited)) return true;
|
||||
if (limited || GetPropW(window, EDIT_REVIEW_PROPERTY)) return false;
|
||||
}
|
||||
// Unhooked/older editors cannot service the safe native query. Keep their
|
||||
// ordinary WM_GETTEXT requests far below Wine's callback-stack limit.
|
||||
limit = std::min(limit, max_unhooked_text);
|
||||
DWORD_PTR size = 0, copied = 0;
|
||||
if (password(window) || !query(window, WM_GETTEXTLENGTH, 0, 0, deadline, size) || password(window)) return false;
|
||||
if (size > limit) { limited = true; return false; }
|
||||
@@ -69,6 +109,23 @@ bool read_text(HWND window, uint64_t deadline, size_t limit, std::vector<WCHAR>
|
||||
text.resize(size);
|
||||
return true;
|
||||
}
|
||||
bool reread_text(HWND window, uint64_t deadline, size_t size, std::vector<WCHAR> &text) {
|
||||
if (password(window)) return false;
|
||||
if (GetPropW(window, EDIT_REVIEW_PROPERTY)) {
|
||||
bool limited = false;
|
||||
return read_hooked_edit(window, deadline, size, text, limited);
|
||||
}
|
||||
if (size > max_unhooked_text) return false;
|
||||
// The original source already supplies a safe capacity. Avoid another
|
||||
// length/attachment round trip on frame-based applications.
|
||||
text.assign(size + 1, 0);
|
||||
DWORD_PTR copied = 0, after = 0;
|
||||
if (!query(window, WM_GETTEXT, text.size(), reinterpret_cast<LPARAM>(text.data()), deadline, copied) ||
|
||||
password(window) || !query(window, WM_GETTEXTLENGTH, 0, 0, deadline, after) ||
|
||||
password(window) || copied != size || after != size) return false;
|
||||
text.resize(size);
|
||||
return true;
|
||||
}
|
||||
std::string caption(HWND window) {
|
||||
// A foreign window's caption is held by USER32. Sending WM_GETTEXT here
|
||||
// could deadlock against an editor hook currently sending WM_COPYDATA to
|
||||
@@ -815,17 +872,9 @@ ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocu
|
||||
RECT bounds{};
|
||||
if (password(read.window) || !IsWindowVisible(read.window) || !GetWindowRect(read.window, &bounds) ||
|
||||
!EqualRect(&read.bounds, &bounds)) { capture.fail("stale"); break; }
|
||||
if (read.text.size() > max_source - capture.source_units) { capture.fail("limited"); break; }
|
||||
capture.source_units += read.text.size();
|
||||
// The first read already supplies a bounded capacity. Copy once, then
|
||||
// verify length, content and protection without another preliminary
|
||||
// length round trip (growth must still reject a truncated old prefix).
|
||||
current.assign(read.text.size()+1,0);
|
||||
DWORD_PTR copied=0,length=0;
|
||||
if(!query(read.window,WM_GETTEXT,current.size(),reinterpret_cast<LPARAM>(current.data()),deadline,copied) ||
|
||||
password(read.window) || !query(read.window,WM_GETTEXTLENGTH,0,0,deadline,length) ||
|
||||
password(read.window) || copied!=read.text.size() || length!=read.text.size())capture.fail("stale");
|
||||
current.resize(read.text.size());
|
||||
// This is a second copy of an already budgeted source, not another
|
||||
// document. Charging it twice made large safe snapshots fail review.
|
||||
if (!reread_text(read.window, deadline, read.text.size(), current)) capture.fail("stale");
|
||||
if(current!=read.text)capture.fail("stale");
|
||||
if (read.layout && !capture.failed) {
|
||||
EditLayout layout{};
|
||||
|
||||
@@ -175,6 +175,41 @@ the affected Wine application as well as the helper.
|
||||
|
||||
## Reader reply stalls
|
||||
|
||||
Long-running text and native-query regressions are also covered by:
|
||||
|
||||
```sh
|
||||
xvfb-run -a ctest --test-dir build -R 'text-limits|review-capture|hook-safety' --output-on-failure
|
||||
```
|
||||
|
||||
`text-limits` checks MSAA truncation, malformed padding, surrogate boundaries,
|
||||
controller request limits and a private stalled reader. A burst of 100 large
|
||||
events must reach the serialized byte budget before the 128-event count limit;
|
||||
an oversized individual event is rejected without consuming queue capacity.
|
||||
|
||||
The review fixtures run with 1 MiB PE stacks in both architectures. They review
|
||||
a 600000-unit ANSI Edit through the real injected hook and cross-process native
|
||||
query, preserving visible text without a large `WM_GETTEXT` message. That output
|
||||
editor starts without a subclass and is attached by review without moving focus.
|
||||
The ordinary ListView reader is delayed beyond its timeout: its buffer must
|
||||
remain committed, further remote allocations must pause, and a later successful
|
||||
read must recover.
|
||||
The existing delayed capture query remains covered separately.
|
||||
|
||||
The hook-safety fixtures also verify complete native review queries at 516000,
|
||||
600000 and 1040000 UTF-16 units, and 2000 background transcript replacement,
|
||||
selection and append cycles without full-text queries. Controller integration
|
||||
checks that oversized speech, braille and SSML requests return an error without
|
||||
reaching the recording reader.
|
||||
|
||||
These are bounded regressions, not a multi-hour soak or proof of audible speech
|
||||
and physical braille. For live acceptance, restart the helper and MUSHclient,
|
||||
then check command history, typing, selection/deletion, incoming plugin output,
|
||||
large-output review, menu return, password exclusion and focus restoration.
|
||||
During a long session record RSS and CPU for MUSHclient, Wine, the helper, reader
|
||||
and speech processes; distinguish continuing memory growth from allocator reuse.
|
||||
If the machine locks up again, retain the previous boot's kernel log as well as
|
||||
reader and Wine diagnostics.
|
||||
|
||||
Run sequentially on a private X display:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -46,6 +46,14 @@ int main(int argc, char **argv) {
|
||||
// The reader's SSML status must reach the caller without conversion.
|
||||
failed |= speakSsml(L"<speak>Controller SSML</speak>", SYMBOL_LEVEL_ALL,
|
||||
SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_CALL_NOT_IMPLEMENTED;
|
||||
WCHAR *oversized = malloc(65538 * sizeof(WCHAR));
|
||||
if (!oversized) return 1;
|
||||
for (unsigned i = 0; i < 65537; ++i) oversized[i] = L'x';
|
||||
oversized[65537] = 0;
|
||||
failed |= speakText(oversized) != ERROR_INVALID_PARAMETER;
|
||||
failed |= brailleMessage(oversized) != ERROR_INVALID_PARAMETER;
|
||||
failed |= speakSsml(oversized, SYMBOL_LEVEL_ALL, SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_INVALID_PARAMETER;
|
||||
free(oversized);
|
||||
}
|
||||
RpcBindingFree(&binding);
|
||||
return failed;
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
#define PROBE_DEPTH 16
|
||||
static WNDPROC original, outer_original;
|
||||
static uintptr_t deepest, hook_stack;
|
||||
static BOOL nested, destroy_on_set;
|
||||
static BOOL nested, destroy_on_set, destroy_on_get, protect_on_length;
|
||||
static unsigned calls, forwarded_sets;
|
||||
static unsigned text_queries;
|
||||
|
||||
typedef struct EditStateProbe {
|
||||
WCHAR *text;
|
||||
@@ -33,6 +34,15 @@ static LRESULT CALLBACK passthrough(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
static LRESULT CALLBACK application_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
volatile char marker = 0;
|
||||
++calls;
|
||||
if (message == WM_GETTEXT) ++text_queries;
|
||||
if (message == WM_GETTEXTLENGTH && protect_on_length) {
|
||||
protect_on_length = FALSE;
|
||||
SendMessageW(window, EM_SETPASSWORDCHAR, '*', 0);
|
||||
}
|
||||
if (message == WM_GETTEXT && destroy_on_get) {
|
||||
DestroyWindow(window);
|
||||
return 0;
|
||||
}
|
||||
if (message == PROBE_MESSAGE) {
|
||||
if (wp) return SendMessageA(window, PROBE_MESSAGE, wp - 1, 0);
|
||||
deepest = (uintptr_t)▮
|
||||
@@ -134,6 +144,9 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
largeText[largeLength] = 0;
|
||||
if (!SetWindowTextA(window, largeText)) { free(largeText); return 12; }
|
||||
// Programmatic transcript replacement is lazy. Navigation/focus still
|
||||
// obtains a complete snapshot through the stack-safe direct query.
|
||||
SendMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
|
||||
EditStateProbe *state = (EditStateProbe *)GetPropW(window, EDIT_STATE_PROPERTY);
|
||||
if (!state || !state->text || state->length != largeLength ||
|
||||
state->text[largeLength] != 0) {
|
||||
@@ -148,12 +161,53 @@ int main(int argc, char **argv) {
|
||||
return 13;
|
||||
}
|
||||
}
|
||||
size_t bytes = offsetof(WineAccessEditText, text) + ((size_t)largeLength + 1) * sizeof(WCHAR);
|
||||
WineAccessEditText *request = calloc(1, bytes);
|
||||
if (!request) return 16;
|
||||
request->capacity = largeLength + 1;
|
||||
SendMessageW(window, RegisterWindowMessageW(EDIT_REVIEW_MESSAGE), 0, (LPARAM)request);
|
||||
if (request->status != 1 || request->copied != largeLength || request->text[largeLength]) return 17;
|
||||
for (DWORD i = 0; i < largeLength; ++i)
|
||||
if (request->text[i] != (unsigned char)largeText[i]) return 18;
|
||||
free(request);
|
||||
free(largeText);
|
||||
printf("large ANSI snapshot: %lu UTF-16 units\n", (unsigned long)state->length);
|
||||
}
|
||||
UnhookWindowsHookEx(text_hook);
|
||||
// Destroy while the accessibility callback is busy; it must defer freeing
|
||||
// its state until the application's callback has returned.
|
||||
unsigned queries_before = text_queries;
|
||||
for (unsigned i = 0; i < 2000; ++i) {
|
||||
SetWindowTextA(window, "incoming transcript line");
|
||||
SendMessageW(window, EM_SETSEL, (WPARAM)-1, (LPARAM)-1);
|
||||
SendMessageW(window, EM_REPLACESEL, FALSE, (LPARAM)L" another line");
|
||||
}
|
||||
if (text_queries != queries_before) return 19;
|
||||
printf("background transcript burst: no full-text reads for 2000 updates\n");
|
||||
SendMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
|
||||
WineAccessEditText *private_query = calloc(1, offsetof(WineAccessEditText, text) + 64 * sizeof(WCHAR));
|
||||
if (!private_query) return 22;
|
||||
private_query->capacity = 64;
|
||||
protect_on_length = TRUE;
|
||||
SendMessageW(window, RegisterWindowMessageW(EDIT_REVIEW_MESSAGE), 0, (LPARAM)private_query);
|
||||
EditStateProbe *protected_state = (EditStateProbe *)GetPropW(window, EDIT_STATE_PROPERTY);
|
||||
if (!protected_state || protected_state->text || protected_state->known || private_query->status) return 23;
|
||||
free(private_query);
|
||||
SendMessageW(window, EM_SETPASSWORDCHAR, 0, 0);
|
||||
// Destroy while the native review callback is busy. Its state must survive
|
||||
// until the application's query returns, then be freed exactly once.
|
||||
destroy_on_get = TRUE;
|
||||
WineAccessEditText *destroy_query = calloc(1, offsetof(WineAccessEditText, text) + 64 * sizeof(WCHAR));
|
||||
if (!destroy_query) return 20;
|
||||
destroy_query->capacity = 64;
|
||||
SendMessageW(window, RegisterWindowMessageW(EDIT_REVIEW_MESSAGE), 0, (LPARAM)destroy_query);
|
||||
if (IsWindow(window) || destroy_query->status) return 21;
|
||||
free(destroy_query);
|
||||
destroy_on_get = FALSE;
|
||||
// Also retain the application's destruction-during-replacement check.
|
||||
window = CreateWindowA("Edit", "initial", WS_POPUP | ES_MULTILINE,
|
||||
0, 0, 200, 100, NULL, NULL, GetModuleHandleW(NULL), NULL);
|
||||
original = (WNDPROC)SetWindowLongPtrA(window, GWLP_WNDPROC, (LONG_PTR)application_proc);
|
||||
focus.hwnd = window;
|
||||
callback(HC_ACTION, 0, (LPARAM)&focus);
|
||||
destroy_on_set = TRUE;
|
||||
SetWindowTextA(window, "destroy");
|
||||
if (IsWindow(window)) return 7;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../list_view.h"
|
||||
#include "../native_query.h"
|
||||
#include "../edit_hook.h"
|
||||
#if __has_include("../review_capture.h")
|
||||
#include "../review_capture.h"
|
||||
#define HAS_CAPTURE 1
|
||||
@@ -173,6 +176,34 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
if (mode != 4) ok &= check(SendMessageW(window, WM_APP + 11, 0, 0), "capture leaves caret and focus unchanged");
|
||||
}
|
||||
SendMessageW(window, WM_APP + 10, 34, 0);
|
||||
auto started = GetTickCount64();
|
||||
auto delayed_row = read_list_view(GetDlgItem(window, 107));
|
||||
ok &= check(!delayed_row.available, "timed-out row is not cached as a successful read");
|
||||
ok &= check(GetTickCount64() - started < 400, "ordinary native read times out promptly");
|
||||
{
|
||||
native_control::RemoteBuffer another(GetDlgItem(window,107), 128);
|
||||
ok &= check(!another, "late native request prevents repeated remote allocations");
|
||||
}
|
||||
Sleep(750);
|
||||
ok &= check(SendMessageW(window,WM_APP + 15,0,0), "ordinary native read retains its remote buffer after timeout");
|
||||
MSG completion{};
|
||||
PeekMessageW(&completion,nullptr,0,0,PM_NOREMOVE);
|
||||
{
|
||||
native_control::RemoteBuffer recovered(GetDlgItem(window,107),128);
|
||||
ok &= check(static_cast<bool>(recovered), "completed native request releases allocation backpressure");
|
||||
}
|
||||
auto recovered_row = read_list_view(GetDlgItem(window,107));
|
||||
ok &= check(recovered_row.available && recovered_row.text.find("ready") != std::string::npos,
|
||||
"ordinary native text recovers after a late reply");
|
||||
SendMessageW(window, WM_APP + 10, 35, 0);
|
||||
ok &= check(GetPropW(GetDlgItem(window,115),EDIT_REVIEW_PROPERTY) == nullptr,
|
||||
"large output starts without a focus-installed subclass");
|
||||
auto large_capture = capture();
|
||||
ok &= check(large_capture.status == "ok" && joined(large_capture).find("aaaa") != std::string::npos,
|
||||
"600000-unit ANSI Edit reviews safely through a real hook on a 1 MiB stack");
|
||||
ok &= check(GetPropW(GetDlgItem(window,115),EDIT_REVIEW_PROPERTY) != nullptr,
|
||||
"large review exercised the native Edit query");
|
||||
// A local provider avoids cross-process WM_GETTEXT marshalling, which
|
||||
// itself truncates at the first NUL. Its BSTR retains malformed padding.
|
||||
WNDCLASSW local{};local.hInstance=GetModuleHandleW(nullptr);
|
||||
|
||||
+37
-1
@@ -3,6 +3,7 @@
|
||||
#include <commctrl.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "../edit_hook.h"
|
||||
|
||||
#define REVIEW_CONFIG (WM_APP + 10)
|
||||
#define REVIEW_CHECK (WM_APP + 11)
|
||||
@@ -11,6 +12,8 @@
|
||||
#define REVIEW_PRIVACY_CHECK (WM_APP + 14)
|
||||
#define REVIEW_REMOTE_CHECK (WM_APP + 15)
|
||||
static HWND editor, output, password, hidden;
|
||||
static HWND ansi_editor;
|
||||
static HHOOK edit_hook;
|
||||
static HWND mdi, mdi_active, mdi_background;
|
||||
static WCHAR *large;
|
||||
static WNDPROC original_edit;
|
||||
@@ -113,6 +116,8 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
frame_pump=FALSE;
|
||||
delay_batch=FALSE;
|
||||
synthetic_containers=0;
|
||||
ShowWindow(ansi_editor, SW_HIDE);
|
||||
ShowWindow(editor, SW_SHOW);
|
||||
ShowWindow(GetDlgItem(window,112),SW_HIDE);
|
||||
ShowWindow(GetDlgItem(window,114),SW_HIDE);
|
||||
ShowWindow(mdi,SW_HIDE);
|
||||
@@ -134,6 +139,34 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
SendMessageW(editor, EM_SETSEL, 0, 0);
|
||||
MoveWindow(editor, 20, 35, 320, 90, TRUE);
|
||||
switch (wp) {
|
||||
case 35: {
|
||||
WCHAR path[MAX_PATH];
|
||||
GetModuleFileNameW(NULL, path, MAX_PATH);
|
||||
WCHAR *separator = path;
|
||||
for (WCHAR *p = path; *p; ++p) if (*p == '\\' || *p == '/') separator = p + 1;
|
||||
#ifdef _WIN64
|
||||
lstrcpyW(separator, L"wine-access-menu.dll");
|
||||
#else
|
||||
lstrcpyW(separator, L"wine-access-menu32.dll");
|
||||
#endif
|
||||
HMODULE module = LoadLibraryW(path);
|
||||
HOOKPROC callback = module ? (HOOKPROC)GetProcAddress(module, "WineAccessMenuHook") : NULL;
|
||||
if (!callback) ExitProcess(6);
|
||||
edit_hook = SetWindowsHookExW(WH_CALLWNDPROC, callback, module, GetCurrentThreadId());
|
||||
if (!edit_hook) ExitProcess(7);
|
||||
char *text = malloc(600001);
|
||||
if (!text) ExitProcess(8);
|
||||
for (unsigned i = 0; i < 600000; ++i) text[i] = i % 80 == 79 ? '\n' : 'a';
|
||||
text[600000] = 0;
|
||||
ShowWindow(editor, SW_HIDE);
|
||||
ShowWindow(ansi_editor, SW_SHOW);
|
||||
SetFocus(output); // The large output has never received focus.
|
||||
SendMessageA(ansi_editor, EM_LIMITTEXT, 2000000, 0);
|
||||
SetWindowTextA(ansi_editor, text);
|
||||
free(text);
|
||||
SendMessageW(ansi_editor, EM_SETSEL, 0, 0);
|
||||
return 1;
|
||||
}
|
||||
case 33: ShowWindow(GetDlgItem(window,114),SW_SHOW); break;
|
||||
case 32: {
|
||||
WCHAR text[16384]; unsigned used = 0;
|
||||
@@ -222,7 +255,7 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
case 15: replace_list_during_read = TRUE; break;
|
||||
case 17: ShowWindow(GetDlgItem(window,107),SW_SHOW); break;
|
||||
case 18: ShowWindow(GetDlgItem(window,107),SW_SHOW); replace_table_during_read=TRUE; break;
|
||||
case 21: ShowWindow(GetDlgItem(window,107),SW_SHOW); delay_table_read=TRUE; break;
|
||||
case 21: case 34: ShowWindow(GetDlgItem(window,107),SW_SHOW); delay_table_read=TRUE; break;
|
||||
case 22:
|
||||
ShowWindow(GetDlgItem(window,109),SW_SHOW);
|
||||
SendMessageW(GetDlgItem(window,109),EM_SETSEL,INT_MAX,INT_MAX);
|
||||
@@ -289,6 +322,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
|
||||
editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_BORDER |
|
||||
ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL, 20, 35, 320, 90, window, (HMENU)101, instance, NULL);
|
||||
original_edit = (WNDPROC)SetWindowLongPtrW(editor, GWLP_WNDPROC, (LONG_PTR)edit_proc);
|
||||
ansi_editor = CreateWindowA("Edit", "", WS_CHILD | WS_BORDER | ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL,
|
||||
20, 35, 320, 90, window, (HMENU)115, instance, NULL);
|
||||
output = CreateWindowW(L"Edit", L"output first\r\noutput second", WS_CHILD | WS_VISIBLE |
|
||||
ES_MULTILINE | ES_READONLY | WS_BORDER, 20, 205, 320, 80, window, (HMENU)102, instance, NULL);
|
||||
password = CreateWindowW(L"Edit", L"private-review-secret", WS_CHILD | WS_VISIBLE |
|
||||
@@ -349,5 +384,6 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
|
||||
} else if (GetMessageW(&message,NULL,0,0)<=0)break;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
if (edit_hook) UnhookWindowsHookEx(edit_hook);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "../text_limits.h"
|
||||
#include "../reader_events.h"
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
static bool check(bool value, const char *message) {
|
||||
if (!value) std::fprintf(stderr, "FAIL: %s\n", message);
|
||||
return value;
|
||||
}
|
||||
|
||||
static GVariant *event(const std::string &text, const char *kind = "value") {
|
||||
return g_variant_new("(sssssssiis)", "source", kind, text.c_str(), "edit", "", "", "", 0, 0, "test");
|
||||
}
|
||||
|
||||
int main() {
|
||||
bool ok = true;
|
||||
std::vector<WCHAR> large(max_controller_units + 2, L'a');
|
||||
large.back() = 0;
|
||||
ok &= check(!controller_text_fits(large.data()), "oversized controller input rejected");
|
||||
large[max_controller_units] = 0;
|
||||
ok &= check(controller_text_fits(large.data()), "maximum controller input accepted");
|
||||
ok &= check(bounded_accessible_text(large.data(), large.size()).size() == max_accessible_units,
|
||||
"large accessible value is bounded before UTF-8 allocation");
|
||||
large[max_accessible_units - 1] = 0xd800;
|
||||
large[max_accessible_units] = 0xdc00;
|
||||
ok &= check(bounded_accessible_text(large.data(), large.size()).size() == max_accessible_units - 1,
|
||||
"truncation does not split a surrogate pair");
|
||||
const WCHAR padded[] = {L'o', L'k', 0, 0xd800};
|
||||
ok &= check(bounded_accessible_text(padded, 4) == "ok", "malformed BSTR padding excluded");
|
||||
|
||||
GError *error = nullptr;
|
||||
auto *connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
|
||||
if (!connection) {
|
||||
std::fprintf(stderr, "FAIL: private session bus: %s\n", error ? error->message : "unavailable");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
const char *xml = "<node><interface name='org.stormux.LimitTest'>"
|
||||
"<method name='PresentAccessibleEvent'>"
|
||||
"<arg type='s' direction='in'/><arg type='s' direction='in'/><arg type='s' direction='in'/>"
|
||||
"<arg type='s' direction='in'/><arg type='s' direction='in'/><arg type='s' direction='in'/>"
|
||||
"<arg type='s' direction='in'/><arg type='i' direction='in'/><arg type='i' direction='in'/>"
|
||||
"<arg type='s' direction='in'/><arg type='b' direction='out'/></method></interface></node>";
|
||||
auto *info = g_dbus_node_info_new_for_xml(xml, nullptr);
|
||||
static const GDBusInterfaceVTable table{
|
||||
[](GDBusConnection *, const gchar *, const gchar *, const gchar *, const gchar *,
|
||||
GVariant *, GDBusMethodInvocation *invocation, gpointer) {
|
||||
g_dbus_method_invocation_return_value(invocation, g_variant_new("(b)", TRUE));
|
||||
}, nullptr, nullptr, {nullptr}};
|
||||
guint object = g_dbus_connection_register_object(connection, "/test", info->interfaces[0],
|
||||
&table, nullptr, nullptr, nullptr);
|
||||
ok &= check(object != 0, "recording reader object registered");
|
||||
ReaderEndpoint endpoint{g_dbus_connection_get_unique_name(connection), "/test", "org.stormux.LimitTest"};
|
||||
ReaderEventQueue queue;
|
||||
ok &= check(queue.start(connection, endpoint), "reader queue starts");
|
||||
ok &= check(queue.submit(event("focus", "focus")), "focus dependency accepted");
|
||||
// Do not dispatch this connection's default GLib context: its first method
|
||||
// call withholds the reply while the event thread fills the pending queue.
|
||||
Sleep(20);
|
||||
ok &= check(!queue.submit(event(std::string(max_reader_event_bytes, 'x'))),
|
||||
"oversized single event rejected");
|
||||
const std::string payload(100000, 'x');
|
||||
unsigned accepted = 0;
|
||||
auto started = GetTickCount64();
|
||||
for (unsigned i = 0; i < 100; ++i) accepted += queue.submit(event(payload));
|
||||
ok &= check(GetTickCount64() - started < 400, "flood completed before reader reply timeout");
|
||||
ok &= check(accepted == 10, "byte budget rejects large events before the 128-event count limit");
|
||||
queue.stop();
|
||||
if (object) g_dbus_connection_unregister_object(connection, object);
|
||||
g_dbus_node_info_unref(info);
|
||||
g_object_unref(connection);
|
||||
if (ok) std::puts("PASS: bounded MSAA/controller text, Unicode boundaries and reader queue byte budget");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
constexpr size_t max_accessible_units = 4096;
|
||||
constexpr size_t max_controller_units = 65536;
|
||||
constexpr size_t max_reader_event_bytes = 131072;
|
||||
constexpr size_t max_reader_queue_bytes = 1048576;
|
||||
|
||||
inline std::string bounded_accessible_text(const WCHAR *text, size_t length) {
|
||||
if (!text) return {};
|
||||
length = std::min(length, max_accessible_units);
|
||||
// MSAA strings can include padding past their first terminating NUL.
|
||||
size_t used = 0;
|
||||
while (used < length && text[used]) ++used;
|
||||
if (used && text[used - 1] >= 0xd800 && text[used - 1] <= 0xdbff) --used;
|
||||
if (!used) return {};
|
||||
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text,
|
||||
static_cast<int>(used), nullptr, 0, nullptr, nullptr);
|
||||
if (size <= 0) return {};
|
||||
std::string result(size, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text,
|
||||
static_cast<int>(used), result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
inline bool controller_text_fits(const WCHAR *text) {
|
||||
if (!text) return false;
|
||||
for (size_t i = 0; i <= max_controller_units; ++i)
|
||||
if (!text[i]) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in new issue
Block a user