Read native Wine list-view rows in 32-bit and 64-bit applications

This commit is contained in:
Storm Dragon committed 2026-10-10 02:03:56 -04:00
1 parent 9aad6748e0
commit d7ea005a1f
8 files changed
+356 -4

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ add_custom_command(
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
VERBATIM)
add_executable(cthulhu-wine-access main.cpp "${generated_server}")
add_executable(cthulhu-wine-access main.cpp list_view.cpp "${generated_server}")
add_dependencies(cthulhu-wine-access wine-access-menu)
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
+10
View File
@@ -110,6 +110,16 @@ contains stable comma-separated state labels decoded from the MSAA bitmask;
protected values are excluded. Readers should filter name/state changes to
their focused source and distinguish these labels from editor diagnostics.
Standard `SysListView32` controls now report the current row's text columns,
selection and position, including 32-bit installers driven by the 64-bit
helper. Wine can expose these controls only as a generic client named `List1`,
so the helper queries the native control with target-architecture structures.
When a populated list has no focused or selected row, focus reads its first
row without changing selection. A bounded focus poll reports row navigation
when Wine omits selection events. Up to eight columns and 1023 UTF-16 units
per column are read. This does not add 32-bit menu or editor hooks, column
navigation, or routable object review.
This is editing feedback, not a full text-review provider: Say All, review-cursor
commands and braille routing into Wine text are not implemented. Typing echo
remains the reader's responsibility. RichEdit and 32-bit editor hooks are not
+125
View File
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "list_view.h"
#include <commctrl.h>
#include <cstdint>
#include <cstddef>
#include <cstring>
#include <iterator>
namespace {
bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *result) {
return SendMessageTimeoutW(window, message, wp, lp, SMTO_ABORTIFHUNG,
100, result) != 0;
}
// LVM_GETITEMTEXT is not marshalled by USER32. The structure and text buffer
// must live in the target process, with pointers matching its architecture.
struct Item32 {
uint32_t mask;
int32_t item, subItem;
uint32_t state, stateMask, text;
int32_t capacity;
};
struct Item64 {
uint32_t mask;
int32_t item, subItem;
uint32_t state, stateMask, padding;
uint64_t text;
int32_t capacity;
uint32_t padding2;
};
static_assert(offsetof(Item32, text) == 20);
static_assert(offsetof(Item64, text) == 24);
constexpr size_t textOffset = 128;
constexpr size_t textCapacity = 1024;
constexpr size_t bufferSize = textOffset + textCapacity * sizeof(wchar_t);
}
bool is_list_view(HWND window) {
wchar_t className[64] = {};
return GetClassNameW(window, className, std::size(className)) &&
lstrcmpiW(className, L"SysListView32") == 0;
}
int list_view_index(HWND window, int *count) {
DWORD_PTR result = 0;
*count = 0;
if (!query(window, LVM_GETITEMCOUNT, 0, 0, &result) ||
static_cast<LONG_PTR>(result) <= 0)
return -1;
*count = static_cast<int>(result);
if (query(window, LVM_GETNEXTITEM, static_cast<WPARAM>(-1), LVNI_FOCUSED,
&result) && static_cast<LONG_PTR>(result) >= 0)
return static_cast<int>(result);
if (query(window, LVM_GETNEXTITEM, static_cast<WPARAM>(-1), LVNI_SELECTED,
&result) && static_cast<LONG_PTR>(result) >= 0)
return static_cast<int>(result);
// An installer can focus a populated list without selecting any row.
return 0;
}
ListViewRow read_list_view(HWND window) {
ListViewRow row;
row.index = list_view_index(window, &row.count);
if (row.index < 0 || row.index >= row.count)
return row;
DWORD_PTR state = 0;
if (query(window, LVM_GETITEMSTATE, row.index, LVIS_SELECTED, &state))
row.selected = state & LVIS_SELECTED;
DWORD processId = 0;
GetWindowThreadProcessId(window, &processId);
HANDLE process = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ |
PROCESS_VM_WRITE | PROCESS_QUERY_LIMITED_INFORMATION,
FALSE, processId);
if (process == nullptr) return row;
BOOL wow64 = FALSE;
if (!IsWow64Process(process, &wow64)) {
CloseHandle(process);
return row;
}
void *remote = VirtualAllocEx(process, nullptr, bufferSize,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (remote == nullptr || (wow64 && reinterpret_cast<uintptr_t>(remote) > UINT32_MAX)) {
if (remote != nullptr) VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return row;
}
DWORD_PTR header = 0, columnCount = 1;
if (query(window, LVM_GETHEADER, 0, 0, &header) && header != 0) {
DWORD_PTR columns = 0;
if (query(reinterpret_cast<HWND>(header), HDM_GETITEMCOUNT, 0, 0, &columns) &&
columns > 0) columnCount = columns;
}
// Keep provider work and the protocol payload bounded even for huge tables.
if (columnCount > 8) columnCount = 8;
for (DWORD_PTR column = 0; column < columnCount; ++column) {
BYTE buffer[bufferSize] = {};
const auto textAddress = reinterpret_cast<uintptr_t>(remote) + textOffset;
if (wow64) {
Item32 item{LVIF_TEXT, row.index, static_cast<int32_t>(column),
0, 0, static_cast<uint32_t>(textAddress), textCapacity};
memcpy(buffer, &item, sizeof(item));
} else {
Item64 item{LVIF_TEXT, row.index, static_cast<int32_t>(column),
0, 0, 0, textAddress, textCapacity, 0};
memcpy(buffer, &item, sizeof(item));
}
DWORD_PTR result = 0;
wchar_t text[textCapacity] = {};
if (!WriteProcessMemory(process, remote, buffer, sizeof(buffer), nullptr) ||
!query(window, LVM_GETITEMTEXTW, row.index, reinterpret_cast<LPARAM>(remote), &result) ||
!ReadProcessMemory(process, reinterpret_cast<void *>(textAddress), text,
sizeof(text), nullptr))
break;
text[textCapacity - 1] = L'\0';
if (!text[0]) continue;
char utf8[textCapacity * 4] = {};
if (!WideCharToMultiByte(CP_UTF8, 0, text, -1, utf8, sizeof(utf8), nullptr, nullptr))
continue;
if (!row.text.empty()) row.text += ", ";
row.text += utf8;
}
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return row;
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
#include <string>
struct ListViewRow {
std::string text;
int index = -1;
int count = 0;
bool selected = false;
};
bool is_list_view(HWND window);
ListViewRow read_list_view(HWND window);
int list_view_index(HWND window, int *count);
+65
View File
@@ -4,6 +4,7 @@
#include "menu_bridge.h"
#include "edit_hook.h"
#include "msaa_states.h"
#include "list_view.h"
#include <commctrl.h>
#include <gio/gio.h>
@@ -31,6 +32,10 @@ bool editReaderActive = false;
std::string lastMenuEvent;
ULONGLONG lastMenuOwner = 0;
HWND lastTextControl = nullptr;
HWND lastListControl = nullptr;
int lastListIndex = -1;
int lastListCount = 0;
bool lastListSelected = false;
// Winegcc uses 16-bit wchar_t; Linux libc's wcs* functions expect 32-bit
// wchar_t. Use Wine's Unicode APIs or explicit code-unit comparisons.
@@ -266,6 +271,22 @@ ControlDescription describe_standard_control(HWND window) {
return control;
}
if (is_list_view(window)) {
find_control_label(window, label, std::size(label));
control.name = to_utf8(label);
control.role = "list";
const auto row = read_list_view(window);
control.value = row.text;
if (!row.text.empty()) control.position = row.index + 1;
control.count = row.count;
if (row.selected) control.states = "selected";
lastListControl = window;
lastListIndex = row.index;
lastListCount = row.count;
lastListSelected = row.selected;
return control;
}
if (lstrcmpW(className, L"ListBox") == 0 ||
lstrcmpW(className, L"ComboLBox") == 0) {
find_control_label(window, label, std::size(label));
@@ -333,6 +354,34 @@ bool present_standard_focus(HWND window) {
control, window_title(window));
}
void poll_list_focus(bool refresh = false) {
GUITHREADINFO info{};
info.cbSize = sizeof(info);
HWND foreground = GetForegroundWindow();
if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) &&
!(info.flags & GUI_INMENUMODE) && is_list_view(info.hwndFocus)) {
int count = 0;
const int index = list_view_index(info.hwndFocus, &count);
DWORD_PTR state = 0;
if (index >= 0)
SendMessageTimeoutW(info.hwndFocus, LVM_GETITEMSTATE, index, LVIS_SELECTED,
SMTO_ABORTIFHUNG, 100, &state);
const bool selected = state & LVIS_SELECTED;
if (info.hwndFocus != lastListControl) {
present_standard_focus(info.hwndFocus);
} else if (refresh || index != lastListIndex || count != lastListCount || selected != lastListSelected) {
const auto control = describe_standard_control(info.hwndFocus);
present_accessible_event(source_id(info.hwndFocus, "standard"), "selection",
control, window_title(info.hwndFocus));
}
return;
}
lastListControl = nullptr;
lastListIndex = -1;
lastListCount = 0;
lastListSelected = false;
}
void present_menu_event(const WineAccessMenuEvent &event) {
HWND owner = reinterpret_cast<HWND>(static_cast<UINT_PTR>(event.owner));
if (owner == nullptr)
@@ -503,6 +552,13 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
return;
if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
return;
// Wine's MSAA provider exposes native list views as named clients, without
// their rows. Use the standard-control query even when that name is present.
if (is_list_view(window)) {
if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window);
else poll_list_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE);
return;
}
IAccessible *accessible = nullptr;
VARIANT child{};
VariantInit(&child);
@@ -764,10 +820,19 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
static_cast<unsigned long>(GetLastError()));
}
MSG message{};
// Native list views also omit row-selection WinEvents in Wine. Observe the
// focused row with scalar queries; retrieve its text only when it changes.
const UINT_PTR listTimer = dialogReaderEnabled ? SetTimer(nullptr, 0, 100, nullptr) : 0;
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
message.wParam == listTimer) {
poll_list_focus();
continue;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
if (listTimer) KillTimer(nullptr, listTimer);
if (hook != nullptr)
UnhookWinEvent(hook);
if (menuHook != nullptr)
+26
View File
@@ -73,6 +73,32 @@ reader's `docs/testing/wine-accessibility.md` for current evidence and live
acceptance gates. Run Xvfb fixtures sequentially; concurrent automatic display
allocation can collide.
## List-view checks
```sh
xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture list
xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture list --32-bit
```
The second command requires `i686-w64-mingw32-gcc`. Both use a private prefix
and recording D-Bus service. They verify first-row reading without selecting
it, two columns, Unicode and literal ampersands, Down/Up, button/list focus
return, changed row text on a value event, and deletion of all rows without
stale output. Run them sequentially. Both architecture fixtures passed on
October 10, as did the existing menu, editor, actual Notepad and CTest MSAA
state checks. The reader's focused Wine race checks also passed.
The BARD Express bootstrapper was inspected in place on October 10. Its
32-bit `SysListView32` has one row: `Pending, Microsoft .NET Framework 4.7.2 Full`.
The new query code retrieved that row without changing the installer. This
does not establish audible output or successful prerequisite installation.
After installing the helper and restarting Cthulhu, Tab to the prerequisite
list: expect the instructions, list role, pending component and `1 of 1`.
Check arrows and Tab/Shift+Tab back to Install/Cancel. Also check a multirow
native list, normal menus, editor navigation, switching to a native Linux
application, and reader shutdown. Verify speech and physical braille separately.
## Live Notepad acceptance
Install all three helper files together and use the matching Cthulhu-Go source.
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#define UNICODE
#define _UNICODE
#include <windows.h>
#include <commctrl.h>
static HWND list, button;
static int step;
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if (message == WM_TIMER) {
switch (++step) {
case 1: case 2:
// Exercise keyboard processing, not synthetic accessibility events.
SendMessageW(list, WM_KEYDOWN, VK_DOWN, 1);
break;
case 3:
SendMessageW(list, WM_KEYDOWN, VK_UP, 1);
break;
case 4: SetFocus(button); break;
case 5: SetFocus(list); break;
case 6: {
LVITEMW item = {0};
item.pszText = L"Ready";
SendMessageW(list, LVM_SETITEMTEXTW, 0, (LPARAM)&item);
NotifyWinEvent(EVENT_OBJECT_VALUECHANGE, list, OBJID_CLIENT, CHILDID_SELF);
break;
}
case 7: SendMessageW(list, LVM_DELETEALLITEMS, 0, 0); break;
default: DestroyWindow(window);
}
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)args; (void)show;
INITCOMMONCONTROLSEX controls = {sizeof(controls), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
WNDCLASSW cls = {0};
cls.hInstance = instance;
cls.lpszClassName = L"WineAccessListFixture";
cls.lpfnWndProc = window_proc;
RegisterClassW(&cls);
HWND window = CreateWindowW(cls.lpszClassName, L"Wine list fixture", WS_OVERLAPPEDWINDOW,
120, 120, 600, 300, NULL, NULL, instance, NULL);
button = CreateWindowW(L"Button", L"Return to control", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
10, 240, 200, 30, window, NULL, instance, NULL);
CreateWindowW(L"Static", L"Required components", WS_CHILD | WS_VISIBLE,
10, 10, 550, 30, window, NULL, instance, NULL);
list = CreateWindowW(WC_LISTVIEWW, L"List1", WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT,
10, 50, 550, 180, window, NULL, instance, NULL);
LVCOLUMNW column = {0};
column.mask = LVCF_TEXT | LVCF_WIDTH;
column.cx = 150;
column.pszText = L"Status";
SendMessageW(list, LVM_INSERTCOLUMNW, 0, (LPARAM)&column);
column.cx = 350;
column.pszText = L"Component";
SendMessageW(list, LVM_INSERTCOLUMNW, 1, (LPARAM)&column);
LVITEMW item = {0};
item.mask = LVIF_TEXT;
item.pszText = L"Pending";
SendMessageW(list, LVM_INSERTITEMW, 0, (LPARAM)&item);
item.iSubItem = 1;
item.pszText = L"Runtime & tools β";
SendMessageW(list, LVM_SETITEMTEXTW, 0, (LPARAM)&item);
item.iItem = 1;
item.iSubItem = 0;
item.pszText = L"Installed";
SendMessageW(list, LVM_INSERTITEMW, 0, (LPARAM)&item);
item.iSubItem = 1;
item.pszText = L"Second component";
SendMessageW(list, LVM_SETITEMTEXTW, 1, (LPARAM)&item);
ShowWindow(window, SW_SHOW);
SetForegroundWindow(window);
// Match the installer: initially no row is selected or focused.
SetFocus(list);
SetTimer(window, 1, 500, NULL);
MSG message;
while (GetMessageW(&message, NULL, 0, 0) > 0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
}
+27 -3
View File
@@ -16,10 +16,13 @@ from gi.repository import Gio, GLib
parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build")
parser.add_argument("--session", action="store_true")
parser.add_argument("--fixture", choices=("menu", "edit", "notepad"), default="menu")
parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list"), default="menu")
parser.add_argument("--32-bit", dest="is32Bit", action="store_true")
parser.add_argument("--late-helper", action="store_true")
parser.add_argument("--restart-helper", action="store_true")
args = parser.parse_args()
if args.is32Bit and args.fixture != "list":
parser.error("32-bit checks currently require --fixture list")
if (args.late_helper or args.restart_helper) and args.fixture != "edit":
parser.error("helper lifecycle checks require --fixture edit")
build = Path(args.build_dir).resolve()
@@ -27,6 +30,7 @@ if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture,
*(["--32-bit"] if args.is32Bit else []),
*(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])]))
@@ -56,9 +60,10 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
ready = Path(temporary) / "ready"
env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\")
fixture = Path(temporary) / "menu-fixture.exe"
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows",
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(args.fixture + "_fixture.c")),
"-luser32"], check=True)
"-luser32", "-lcomctl32"], check=True)
helper = None
app = None
try:
@@ -104,6 +109,25 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
focused = event[0]
elif event[1] == "text":
assert event[0] == focused, f"text sent without establishing focus: {event!r}"
if args.fixture == "list":
listEvents = [e for e in events if e[3] == "list"]
for event in listEvents:
print(event[1:9])
assert any(e[1] == "focus" and e[2] == "Required components" and
e[4] == "Pending, Runtime & tools β" and e[7:9] == (1, 2)
and e[5] == "" for e in listEvents), "unselected initial row or label missing"
assert any(e[1] == "selection" and e[4] == "Installed, Second component" and
e[7:9] == (2, 2) for e in listEvents), "Down lost second row/columns"
assert any(e[1] == "selection" and e[4] == "Pending, Runtime & tools β" and
e[7:9] == (1, 2) for e in listEvents), "Up lost first row"
assert any(e[1] == "focus" and e[4] == "Pending, Runtime & tools β" and
e[5] == "selected" for e in listEvents), "return to list lost selection"
assert any(e[4] == "Ready, Runtime & tools β" for e in listEvents), "value event lost changed row text"
assert any(e[4] == "" and e[7:9] == (0, 0) for e in listEvents), "empty list retained stale row"
assert any(e[2] == "Return to control" for e in events), "button focus missing"
assert not any(e[2] == "List1" for e in events), "generic MSAA client leaked"
print("PASS: native list focus, columns, Unicode, navigation, return, and empty list")
sys.exit(0)
if args.fixture == "notepad":
dialogEvents = [e for e in events if e[2] in ("Yes", "No", "Cancel")]
for event in dialogEvents: