From d7ea005a1f3eaeaf4b76eb9874efe5a58404b2e8 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 10 Oct 2026 02:03:56 -0400 Subject: [PATCH] Read native Wine list-view rows in 32-bit and 64-bit applications --- CMakeLists.txt | 2 +- README.md | 10 +++ list_view.cpp | 125 ++++++++++++++++++++++++++++++++++++++ list_view.h | 15 +++++ main.cpp | 65 ++++++++++++++++++++ tests/README.md | 26 ++++++++ tests/list_fixture.c | 87 ++++++++++++++++++++++++++ tests/menu_integration.py | 30 ++++++++- 8 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 list_view.cpp create mode 100644 list_view.h create mode 100644 tests/list_fixture.c diff --git a/CMakeLists.txt b/CMakeLists.txt index f5e6848..10ab6bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}") diff --git a/README.md b/README.md index 581f3fa..90dc853 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/list_view.cpp b/list_view.cpp new file mode 100644 index 0000000..50bc4ab --- /dev/null +++ b/list_view.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "list_view.h" +#include +#include +#include +#include +#include + +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(result) <= 0) + return -1; + *count = static_cast(result); + if (query(window, LVM_GETNEXTITEM, static_cast(-1), LVNI_FOCUSED, + &result) && static_cast(result) >= 0) + return static_cast(result); + if (query(window, LVM_GETNEXTITEM, static_cast(-1), LVNI_SELECTED, + &result) && static_cast(result) >= 0) + return static_cast(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(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(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(remote) + textOffset; + if (wow64) { + Item32 item{LVIF_TEXT, row.index, static_cast(column), + 0, 0, static_cast(textAddress), textCapacity}; + memcpy(buffer, &item, sizeof(item)); + } else { + Item64 item{LVIF_TEXT, row.index, static_cast(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(remote), &result) || + !ReadProcessMemory(process, reinterpret_cast(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; +} diff --git a/list_view.h b/list_view.h new file mode 100644 index 0000000..4c91b86 --- /dev/null +++ b/list_view.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include + +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); diff --git a/main.cpp b/main.cpp index 49790b4..3a88663 100644 --- a/main.cpp +++ b/main.cpp @@ -4,6 +4,7 @@ #include "menu_bridge.h" #include "edit_hook.h" #include "msaa_states.h" +#include "list_view.h" #include #include @@ -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(static_cast(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(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) diff --git a/tests/README.md b/tests/README.md index 573d599..deb3bea 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. diff --git a/tests/list_fixture.c b/tests/list_fixture.c new file mode 100644 index 0000000..0865650 --- /dev/null +++ b/tests/list_fixture.c @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#define UNICODE +#define _UNICODE +#include +#include + +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; +} diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 5c08509..a163030 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -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: