Bug fixes and reorganization of code.

This commit is contained in:
ak47 committed 2026-10-10 22:27:12 -04:00
1 parent 5f78168c57
commit cbce83581a
10 files changed
+148 -81

No files matched your search

+8
View File
@@ -33,6 +33,10 @@ project yet; it needs a 32-bit Winegcc and 32-bit GLib/GIO development setup.
## Build
On Arch Linux, the [PKGBUILD](distro-packages/ArchLinux/wine-access-git/README.md)
provides package-managed installation and upgrades. Choose one installation
method to avoid a local copy taking precedence over the system package.
The convenience script builds in `./build`. Running it as a user installs to
`$HOME/.local/libexec/cthulhu/wine`; running it as root installs system wide to
`/usr/libexec/cthulhu/wine`:
@@ -148,6 +152,10 @@ and values. Edit and combo-box labels can be visible siblings to the left,
even when the labels were created after the fields. Application-owned MSAA
lists retain their own names, roles and item data.
Control values and window titles retain literal ampersands. Button and static
label captions decode keyboard mnemonics and escaped `&&`; static labels with
`SS_NOPREFIX` retain their literal text.
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
@@ -8,8 +8,8 @@ url='https://git.stormux.org/storm/wine-access'
license=('LGPL-2.1-or-later')
depends=('glib2' 'wine')
makedepends=('cmake' 'git' 'pkgconf' 'mingw-w64-gcc')
provides=('wine-access')
conflicts=('wine-access')
provides=('wine-access' 'cthulhu-wine-access')
conflicts=('wine-access' 'cthulhu-wine-access')
# Wine's import generation needs native object symbols, which LTO hides.
options=('!lto')
@@ -23,8 +23,18 @@ elsewhere. To use HTTPS after the repository becomes public:
WINE_ACCESS_GIT_URL=https://git.stormux.org/storm/wine-access.git makepkg -si
```
The package installs both Winelib artifacts under
`/usr/libexec/cthulhu/wine/`. If your reader does not discover that system
The package installs the Winelib launcher, its sidecar and the native hook DLL
under `/usr/libexec/cthulhu/wine/`. It provides and conflicts with the older
`cthulhu-wine-access` helper, including `cthulhu-wine-access-git`, so pacman
can replace that package in the installation transaction.
Use this package for subsequent updates rather than also installing through
`build.sh`. If an earlier local installation exists under
`~/.local/libexec/cthulhu/wine`, remove it with `./build.sh -r` from the project
root before using the package: readers may prefer that local copy. Do not
remove package-owned system files with `build.sh -r`.
If your reader does not discover the system
location automatically, set this in the environment of the reader's service:
```sh
+9 -29
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "list_view.h"
#include "control_class.h"
#include "native_query.h"
#include <commctrl.h>
#include <cstdint>
#include <cstddef>
@@ -8,10 +9,7 @@
#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;
}
using native_control::query;
// LVM_GETITEMTEXT is not marshalled by USER32. The structure and text buffer
// must live in the target process, with pointers matching its architecture.
@@ -67,24 +65,9 @@ ListViewRow read_list_view(HWND window) {
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;
}
native_control::RemoteBuffer remote(window, bufferSize);
if (!remote) return row;
const bool wow64 = remote.is32Bit();
DWORD_PTR header = 0, columnCount = 1;
if (query(window, LVM_GETHEADER, 0, 0, &header) && header != 0) {
DWORD_PTR columns = 0;
@@ -95,7 +78,7 @@ ListViewRow read_list_view(HWND window) {
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;
const auto textAddress = remote.address(textOffset);
if (wow64) {
Item32 item{LVIF_TEXT, row.index, static_cast<int32_t>(column),
0, 0, static_cast<uint32_t>(textAddress), textCapacity};
@@ -107,10 +90,9 @@ ListViewRow read_list_view(HWND window) {
}
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))
if (!remote.write(buffer, sizeof(buffer)) ||
!query(window, LVM_GETITEMTEXTW, row.index, static_cast<LPARAM>(remote.address()), &result) ||
!remote.read(text, sizeof(text), textOffset))
break;
text[textCapacity - 1] = L'\0';
if (!text[0]) continue;
@@ -120,7 +102,5 @@ ListViewRow read_list_view(HWND window) {
if (!row.text.empty()) row.text += ", ";
row.text += utf8;
}
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return row;
}
+21 -10
View File
@@ -140,15 +140,21 @@ std::string accessible_property(
// Standard-control queries follow the LGPL wine-a11y reader published by
// Mudb0y, while presentation remains on Cthulhu's D-Bus interface.
void tidy_text(wchar_t *text, bool label = false) {
void strip_mnemonics(wchar_t *text) {
wchar_t *output = text;
for (wchar_t *input = text; *input != L'\0'; ++input) {
if (*input != L'&')
*output++ = *input;
if (*input == L'&') {
if (input[1] != L'&') continue;
++input;
}
*output++ = *input;
}
*output = L'\0';
if (label && output > text && output[-1] == L':')
output[-1] = L'\0';
}
void trim_label(wchar_t *text) {
const int length = lstrlenW(text);
if (length && text[length - 1] == L':') text[length - 1] = L'\0';
}
bool get_control_text(HWND window, wchar_t *text, int capacity) {
@@ -158,7 +164,14 @@ bool get_control_text(HWND window, wchar_t *text, int capacity) {
reinterpret_cast<LPARAM>(text), SMTO_ABORTIFHUNG,
200, &result))
return false;
tidy_text(text);
// Only captions interpret mnemonic markers. Values and window titles
// contain literal text; even captions escape a literal ampersand as &&.
wchar_t className[128] = {};
if (GetClassNameW(window, className, std::size(className)) &&
(wine_access_class_matches(className, L"Button") ||
(wine_access_class_matches(className, L"Static") &&
!(GetWindowLongPtrW(window, GWL_STYLE) & SS_NOPREFIX))))
strip_mnemonics(text);
return text[0] != L'\0';
}
@@ -195,7 +208,7 @@ void find_control_label(HWND window, wchar_t *text, int capacity) {
}
}
if (label && get_control_text(label, text, capacity)) {
tidy_text(text, true);
trim_label(text);
return;
}
}
@@ -210,7 +223,7 @@ void find_control_label(HWND window, wchar_t *text, int capacity) {
if (!wine_access_class_matches(className, L"Static") && !wine_access_class_matches(className, L"Button"))
continue;
if (get_control_text(previous, text, capacity))
tidy_text(text, true);
trim_label(text);
return;
}
}
@@ -218,7 +231,6 @@ void find_control_label(HWND window, wchar_t *text, int capacity) {
std::string window_title(HWND window) {
wchar_t title[512] = {};
GetWindowTextW(GetAncestor(window, GA_ROOT), title, std::size(title));
tidy_text(title);
return to_utf8(title);
}
@@ -362,7 +374,6 @@ ControlDescription describe_standard_control(HWND window) {
SendMessageTimeoutW(window, LB_GETTEXT, selected,
reinterpret_cast<LPARAM>(text), SMTO_ABORTIFHUNG,
200, &length)) {
tidy_text(text);
control.value = to_utf8(text);
}
control.position = static_cast<int>(selected) + 1;
+9 -25
View File
@@ -1,16 +1,14 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "native_items.h"
#include "control_class.h"
#include "native_query.h"
#include <commctrl.h>
#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;
}
using native_control::query;
bool is_tab(HWND window) {
wchar_t className[128] = {};
return GetClassNameW(window, className, std::size(className)) &&
@@ -75,23 +73,11 @@ NativeItem read_native_item(HWND window) {
const auto key = native_item_key(window);
item.key = key;
if (!key.item) return item;
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) return item;
BOOL wow64 = FALSE;
if (!IsWow64Process(process, &wow64)) { CloseHandle(process); return item; }
void *remote = VirtualAllocEx(process, nullptr, bufferSize,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!remote || (wow64 && reinterpret_cast<uintptr_t>(remote) > UINT32_MAX)) {
if (remote) VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return item;
}
native_control::RemoteBuffer remote(window, bufferSize);
if (!remote) return item;
const bool wow64 = remote.is32Bit();
BYTE buffer[bufferSize] = {};
const auto address = reinterpret_cast<uintptr_t>(remote) + textOffset;
const auto address = remote.address(textOffset);
if (tab && wow64) {
const Tab32 data{TCIF_TEXT, 0, 0, static_cast<uint32_t>(address), textCapacity};
memcpy(buffer, &data, sizeof(data));
@@ -108,10 +94,10 @@ NativeItem read_native_item(HWND window) {
memcpy(buffer, &data, sizeof(data));
}
DWORD_PTR result = 0;
if (WriteProcessMemory(process, remote, buffer, sizeof(buffer), nullptr) &&
if (remote.write(buffer, sizeof(buffer)) &&
query(window, tab ? TCM_GETITEMW : TVM_GETITEMW, tab ? key.item - 1 : 0,
reinterpret_cast<LPARAM>(remote), &result) && result &&
ReadProcessMemory(process, remote, buffer, sizeof(buffer), nullptr)) {
static_cast<LPARAM>(remote.address()), &result) && result &&
remote.read(buffer, sizeof(buffer))) {
wchar_t text[textCapacity] = {};
memcpy(text, buffer + textOffset, sizeof(text));
text[textCapacity - 1] = L'\0';
@@ -130,7 +116,5 @@ NativeItem read_native_item(HWND window) {
}
}
}
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return item;
}
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
#include <cstddef>
#include <cstdint>
namespace native_control {
inline bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *result) {
return SendMessageTimeoutW(window, message, wp, lp, SMTO_ABORTIFHUNG,
100, result) != 0;
}
// Common-control messages above WM_USER do not marshal embedded pointers.
// Own the target allocation and process handle together, including failures.
class RemoteBuffer {
public:
RemoteBuffer(HWND window, size_t size) {
DWORD processId = 0;
GetWindowThreadProcessId(window, &processId);
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,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// Every byte of the allocation must be addressable by the target.
if (memory_ && wow64_ &&
(address() > UINT32_MAX || size - 1 > UINT32_MAX - address())) {
VirtualFreeEx(process_, memory_, 0, MEM_RELEASE);
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; }
uintptr_t address(size_t offset = 0) const {
return reinterpret_cast<uintptr_t>(memory_) + offset;
}
bool write(const void *buffer, size_t size) const {
return WriteProcessMemory(process_, 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)),
buffer, size, nullptr) != 0;
}
private:
HANDLE process_ = nullptr;
void *memory_ = nullptr;
BOOL wow64_ = FALSE;
};
} // namespace native_control
+6
View File
@@ -150,6 +150,12 @@ caption. The existing menu, editor, Notepad, list and managed-field fixtures
remain regression checks; an application-owned MSAA provider in the menu
fixture must retain its role instead of being replaced by native queries.
The controls fixture also verifies literal ampersands in combo-box, edit and
list-box values and window titles, escaped ampersands in button/label captions,
and literal `SS_NOPREFIX` labels. Both control architectures exercise the
shared remote allocation used by native tabs and trees; the list fixtures
exercise the same allocation for multi-column rows.
Read-only inspection of foobar2000 v2.26.1 with the new native query code
retrieved `All Music`, `Album List`, and `Default Playlist` from the open
application. This identifies data missed by the old helper; it does not prove
+13 -8
View File
@@ -4,7 +4,7 @@
#include <windows.h>
#include <commctrl.h>
static HWND tabs, atlTabs, tree, combo, edit, button;
static HWND tabs, atlTabs, tree, combo, edit, button, list;
static HTREEITEM root;
static int step;
@@ -35,6 +35,7 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
case 14: SendMessageW(atlTabs, TCM_DELETEALLITEMS, 0, 0); break;
case 15: focus_control(tree); break;
case 16: SendMessageW(tree, TVM_DELETEITEM, 0, (LPARAM)TVI_ROOT); break;
case 17: focus_control(list); break;
default: DestroyWindow(window); break;
}
return 0;
@@ -52,7 +53,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
cls.lpszClassName = L"WineAccessControlsFixture";
cls.lpfnWndProc = window_proc;
RegisterClassW(&cls);
HWND window = CreateWindowW(cls.lpszClassName, L"Wine controls fixture", WS_OVERLAPPEDWINDOW,
HWND window = CreateWindowW(cls.lpszClassName, L"Wine & controls fixture", WS_OVERLAPPEDWINDOW,
120, 120, 700, 440, NULL, NULL, instance, NULL);
WNDCLASSEXW tabClass = {0};
tabClass.cbSize = sizeof(tabClass);
@@ -86,20 +87,24 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
node.item.pszText = L"Artist & album β";
SendMessageW(tree, TVM_INSERTITEMW, 0, (LPARAM)&node);
SendMessageW(tree, TVM_SELECTITEM, TVGN_CARET, (LPARAM)root);
button = CreateWindowW(L"Button", L"Named native button", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
button = CreateWindowW(L"Button", L"&Named && native button", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
10, 310, 250, 30, window, NULL, instance, NULL);
combo = CreateWindowW(L"ComboBox", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST,
390, 110, 200, 150, window, NULL, instance, NULL);
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"by artist");
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"by year");
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"artist & album");
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"year && date");
SendMessageW(combo, CB_SETCURSEL, 0, 0);
edit = CreateWindowW(L"Edit", L"search", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
edit = CreateWindowW(L"Edit", L"search & find", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
390, 170, 200, 25, window, NULL, instance, NULL);
// Labels are deliberately created after their fields, like foobar2000.
CreateWindowW(L"Static", L"View:", WS_CHILD | WS_VISIBLE,
CreateWindowW(L"Static", L"&View && sort:", WS_CHILD | WS_VISIBLE,
330, 110, 50, 25, window, NULL, instance, NULL);
CreateWindowW(L"Static", L"Filter:", WS_CHILD | WS_VISIBLE,
CreateWindowW(L"Static", L"Filter & query:", WS_CHILD | WS_VISIBLE | SS_NOPREFIX,
330, 170, 50, 25, window, NULL, instance, NULL);
list = CreateWindowW(L"ListBox", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
390, 220, 200, 80, window, NULL, instance, NULL);
SendMessageW(list, LB_ADDSTRING, 0, (LPARAM)L"Rock & roll && blues");
SendMessageW(list, LB_SETCURSEL, 0, 0);
ShowWindow(window, SW_SHOW);
SetForegroundWindow(window);
focus_control(tabs);
+10 -5
View File
@@ -121,14 +121,19 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
for e in events), "collapsed tree selection missing"
assert any(e[2] == "All music" and "expanded" in e[5] for e in events), "tree expansion missing"
assert any(e[2] == "Artist & album β" and e[3] == "tree item" for e in events), "tree navigation missing"
assert any(e[1] == "focus" and e[2] == "Named native button" and e[3] == "button"
assert any(e[1] == "focus" and e[2] == "Named & native button" and e[3] == "button"
for e in events), "named generic MSAA client hid button role"
for value in ("by artist", "by year"):
assert any(e[2] == "View" and e[3] == "combo box" and e[4] == value
for value in ("artist & album", "year && date"):
assert any(e[2] == "View & sort" and e[3] == "combo box" and e[4] == value
for e in events), f"combo label/role/value missing: {value}"
assert any(e[4] == "search & find" for e in events), "edit value changed"
if not args.is32Bit:
assert any(e[2] == "Filter" and e[3] == "edit" and e[4] == "search"
for e in events), "late native edit label missing"
assert any(e[2] == "Filter & query" and e[3] == "edit" and e[4] == "search & find"
for e in events), "late native edit label/value missing"
assert any(e[3] == "list" and e[4] == "Rock & roll && blues"
for e in events), "list box lost literal ampersands"
controlEvents = [e for e in events if e[3] in ("page tab", "tree item", "button", "combo box", "edit")]
assert controlEvents and all(e[9] == "Wine & controls fixture" for e in controlEvents), "window title changed"
assert any(e[2] == "" and e[3] == "page tab" and e[7:9] == (0, 0)
for e in events), "empty tabs retained stale caption"
assert any(e[2] == "" and e[3] == "tree" for e in events), "empty tree retained stale item"