More fixes for menus and combo boxes.
This commit is contained in:
1 parent
67ebc28d15
commit
872388a59b
11 files changed
+392
-35
No files matched your search
+17
-1
@@ -8,6 +8,7 @@ find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
find_program(WIDL_EXECUTABLE widl REQUIRED)
|
||||
find_program(MINGW_C_COMPILER x86_64-w64-mingw32-gcc REQUIRED)
|
||||
find_program(MINGW32_C_COMPILER i686-w64-mingw32-gcc REQUIRED)
|
||||
|
||||
# A PE hook DLL can be loaded into ordinary Wine applications without any
|
||||
# GLib dependencies. Keep it alongside the Winelib helper.
|
||||
@@ -21,6 +22,19 @@ add_custom_command(OUTPUT "${menu_hook}"
|
||||
VERBATIM)
|
||||
add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}")
|
||||
|
||||
set(menu_hook32 "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu32.dll")
|
||||
set(menu_host32 "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu-host32.exe")
|
||||
add_custom_command(OUTPUT "${menu_hook32}" "${menu_host32}"
|
||||
COMMAND "${MINGW32_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared
|
||||
-static-libgcc -Wl,--kill-at -DWINE_ACCESS_MENU_ONLY -o "${menu_hook32}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" -luser32
|
||||
COMMAND "${MINGW32_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -mwindows
|
||||
-static-libgcc -o "${menu_host32}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/menu_host32.c" -luser32
|
||||
DEPENDS menu_hook.c menu_bridge.h menu_host32.c
|
||||
VERBATIM)
|
||||
add_custom_target(wine-access-menu32 ALL DEPENDS "${menu_hook32}" "${menu_host32}")
|
||||
|
||||
set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
set(generated_header "${generated_dir}/nvdaController.h")
|
||||
set(generated_server "${generated_dir}/nvdaController_s.c")
|
||||
@@ -34,7 +48,7 @@ add_custom_command(
|
||||
VERBATIM)
|
||||
|
||||
add_executable(cthulhu-wine-access main.cpp list_view.cpp native_items.cpp "${generated_server}")
|
||||
add_dependencies(cthulhu-wine-access wine-access-menu)
|
||||
add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32)
|
||||
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
|
||||
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
|
||||
target_link_libraries(cthulhu-wine-access PRIVATE
|
||||
@@ -62,4 +76,6 @@ install(PROGRAMS
|
||||
install(FILES
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so"
|
||||
"${menu_hook}"
|
||||
"${menu_hook32}"
|
||||
"${menu_host32}"
|
||||
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
|
||||
@@ -25,11 +25,12 @@ Building requires an x86_64 Linux host with:
|
||||
- Make and native GCC/G++ (the script uses CMake's default Makefile generator);
|
||||
- `pkg-config` and the GLib/GIO development files;
|
||||
- Wine development tools: `winegcc`, `wineg++`, and `widl`;
|
||||
- `x86_64-w64-mingw32-gcc` to build the native menu hook DLL
|
||||
- `x86_64-w64-mingw32-gcc` and `i686-w64-mingw32-gcc` to build native menu hooks
|
||||
(`mingw-w64-gcc` on Arch Linux).
|
||||
|
||||
The target is a Winelib executable. A 32-bit build is not provided by this
|
||||
project yet; it needs a 32-bit Winegcc and 32-bit GLib/GIO development setup.
|
||||
The D-Bus helper is a 64-bit Winelib executable. A small 32-bit Windows
|
||||
companion supplies menu hooks for 32-bit applications, without requiring
|
||||
32-bit Linux GLib/GIO development files.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -54,7 +55,7 @@ development packages. Removal does not require these dependencies.
|
||||
Use `./build.sh -r` to remove the local installation, or
|
||||
`sudo ./build.sh -r` to remove the system installation. If a user has no local
|
||||
installation but a system installation exists, the script requests the latter
|
||||
command without running sudo itself. Removal deletes only the three helper
|
||||
command without running sudo itself. Removal deletes only the five helper
|
||||
files and leaves other directory contents untouched.
|
||||
|
||||
Alternative build and install locations may be supplied as positional
|
||||
@@ -72,6 +73,8 @@ That installs the helper and its Winelib sidecar as:
|
||||
$prefix/libexec/cthulhu/wine/cthulhu-wine-access.exe
|
||||
$prefix/libexec/cthulhu/wine/cthulhu-wine-access.exe.so
|
||||
$prefix/libexec/cthulhu/wine/wine-access-menu.dll
|
||||
$prefix/libexec/cthulhu/wine/wine-access-menu32.dll
|
||||
$prefix/libexec/cthulhu/wine/wine-access-menu-host32.exe
|
||||
```
|
||||
|
||||
For Cthulhu-Go, either set `CTHULHU_WINE_ACCESS_HELPER` to the installed
|
||||
@@ -98,15 +101,24 @@ The standard-control reader is deliberately limited to native Wine controls.
|
||||
Custom-drawn, DirectX, and game-specific accessibility APIs remain the
|
||||
responsibility of the application or its accessibility middleware.
|
||||
|
||||
Native menu reading uses an accompanying 64-bit Windows hook DLL. It observes
|
||||
Native menu reading uses accompanying 64-bit and 32-bit Windows hook DLLs. They
|
||||
observe
|
||||
`WM_MENUSELECT` inside Wine applications and sends menu labels, displayed
|
||||
shortcuts, submenu roles, and checked/unavailable states to the helper. The
|
||||
helper queues these messages before forwarding them over D-Bus, suppresses
|
||||
repeated selections, and restores standard-control focus reporting when menus
|
||||
close. The hook applies to the helper's Wine desktop and prefix and is removed
|
||||
on normal shutdown. It is disabled by `--no-dialog-reader`.
|
||||
close. The hooks apply to the helper's Wine desktop and prefix and are removed
|
||||
on normal shutdown. The 32-bit host attaches to the foreground 32-bit UI thread
|
||||
so Wine's global hook chain cannot interfere with 64-bit menus. It exits when the helper dies or its bridge
|
||||
window closes. Menu hooks are disabled by `--no-dialog-reader`. The 32-bit
|
||||
companion handles menus; the editor subclass described below remains 64-bit.
|
||||
|
||||
The same DLL subclasses standard `Edit` controls, including Wine Notepad's
|
||||
Focused combo boxes are checked every 100 ms to recover value changes that
|
||||
Wine does not report through WinEvents. The combo's internal list events use
|
||||
the same source identity as the focused combo, so the reader can accept the
|
||||
new value without a Tab/Shift+Tab round trip.
|
||||
|
||||
The 64-bit DLL subclasses standard `Edit` controls, including Wine Notepad's
|
||||
document area and `WindowsForms10.EDIT.app.*` controls. Windows Forms fields
|
||||
use visible sibling labels immediately to their left, even when the labels
|
||||
were created after the fields. Protected fields report their label and
|
||||
|
||||
@@ -33,7 +33,8 @@ else
|
||||
fi
|
||||
prefix=${2:-"$defaultPrefix"}
|
||||
installDir="${prefix%/}/libexec/cthulhu/wine"
|
||||
installedFiles=(cthulhu-wine-access.exe cthulhu-wine-access.exe.so wine-access-menu.dll)
|
||||
installedFiles=(cthulhu-wine-access.exe cthulhu-wine-access.exe.so wine-access-menu.dll
|
||||
wine-access-menu32.dll wine-access-menu-host32.exe)
|
||||
|
||||
has_installation() {
|
||||
local candidateDir=$1
|
||||
@@ -69,6 +70,7 @@ check_prerequisites() {
|
||||
cmake:cmake make:make gcc:gcc g++:gcc pkg-config:pkgconf
|
||||
winegcc:wine wineg++:wine widl:wine
|
||||
x86_64-w64-mingw32-gcc:mingw-w64-gcc
|
||||
i686-w64-mingw32-gcc:mingw-w64-gcc
|
||||
)
|
||||
|
||||
for requirement in "${requirements[@]}"; do
|
||||
|
||||
@@ -23,7 +23,8 @@ 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 the Winelib launcher, its sidecar and the native hook DLL
|
||||
The package installs the Winelib launcher, its sidecar, both native menu hook
|
||||
DLLs, and the 32-bit menu host
|
||||
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.
|
||||
|
||||
@@ -41,6 +41,30 @@ bool lastListSelected = false;
|
||||
int lastListChecked = -1;
|
||||
HWND lastNativeControl = nullptr;
|
||||
NativeItemKey lastNativeItem;
|
||||
HWND lastComboControl = nullptr;
|
||||
std::string lastComboValue;
|
||||
|
||||
bool is_combo_box(HWND window) {
|
||||
wchar_t className[128] = {};
|
||||
return GetClassNameW(window, className, std::size(className)) &&
|
||||
(wine_access_class_matches(className, L"ComboBox") ||
|
||||
wine_access_class_matches(className, L"ComboBoxEx32"));
|
||||
}
|
||||
|
||||
HWND combo_event_control(HWND window, DWORD event) {
|
||||
if (is_combo_box(window)) return window;
|
||||
wchar_t className[128] = {};
|
||||
const bool comboList = GetClassNameW(window, className, std::size(className)) &&
|
||||
wine_access_class_matches(className, L"ComboLBox");
|
||||
if (event != EVENT_OBJECT_FOCUS && !comboList) return nullptr;
|
||||
GUITHREADINFO info{};
|
||||
info.cbSize = sizeof(info);
|
||||
HWND foreground = GetForegroundWindow();
|
||||
if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) &&
|
||||
is_combo_box(info.hwndFocus))
|
||||
return info.hwndFocus;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -321,6 +345,8 @@ ControlDescription describe_standard_control(HWND window) {
|
||||
control.name = to_utf8(label);
|
||||
control.role = "combo box";
|
||||
control.value = to_utf8(text);
|
||||
lastComboControl = window;
|
||||
lastComboValue = control.value;
|
||||
return control;
|
||||
}
|
||||
|
||||
@@ -427,8 +453,14 @@ void poll_control_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_native_item_control(info.hwndFocus)) {
|
||||
const bool focused = foreground &&
|
||||
GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) &&
|
||||
!(info.flags & GUI_INMENUMODE);
|
||||
if (!focused || !is_combo_box(info.hwndFocus)) {
|
||||
lastComboControl = nullptr;
|
||||
lastComboValue.clear();
|
||||
}
|
||||
if (focused && is_native_item_control(info.hwndFocus)) {
|
||||
lastListControl = nullptr;
|
||||
lastListIndex = -1;
|
||||
lastListCount = 0;
|
||||
@@ -446,8 +478,7 @@ void poll_control_focus(bool refresh = false) {
|
||||
}
|
||||
lastNativeControl = nullptr;
|
||||
lastNativeItem = {};
|
||||
if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) &&
|
||||
!(info.flags & GUI_INMENUMODE) && is_list_view(info.hwndFocus)) {
|
||||
if (focused && is_list_view(info.hwndFocus)) {
|
||||
int count = 0;
|
||||
const int index = list_view_index(info.hwndFocus, &count);
|
||||
DWORD_PTR state = 0;
|
||||
@@ -471,6 +502,19 @@ void poll_control_focus(bool refresh = false) {
|
||||
lastListCount = 0;
|
||||
lastListSelected = false;
|
||||
lastListChecked = -1;
|
||||
if (focused && is_combo_box(info.hwndFocus)) {
|
||||
if (info.hwndFocus != lastComboControl) {
|
||||
present_standard_focus(info.hwndFocus);
|
||||
} else {
|
||||
wchar_t text[512] = {};
|
||||
get_control_text(info.hwndFocus, text, std::size(text));
|
||||
if (refresh || to_utf8(text) != lastComboValue) {
|
||||
const auto control = describe_standard_control(info.hwndFocus);
|
||||
present_accessible_event(source_id(info.hwndFocus, "standard"), "value",
|
||||
control, window_title(info.hwndFocus));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void present_menu_event(const WineAccessMenuEvent &event) {
|
||||
@@ -625,21 +669,43 @@ LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
||||
return DefWindowProcW(window, message, wp, lp);
|
||||
}
|
||||
|
||||
HMODULE load_menu_hook() {
|
||||
wchar_t path[4096] = {};
|
||||
const DWORD length = GetModuleFileNameW(nullptr, path, std::size(path));
|
||||
if (length == 0 || length >= std::size(path))
|
||||
return nullptr;
|
||||
bool helper_file_path(wchar_t *path, size_t capacity, const wchar_t *name) {
|
||||
const DWORD length = GetModuleFileNameW(nullptr, path, capacity);
|
||||
if (length == 0 || length >= capacity)
|
||||
return false;
|
||||
wchar_t *separator = nullptr;
|
||||
for (wchar_t *cursor = path; *cursor; ++cursor)
|
||||
if (*cursor == L'\\' || *cursor == L'/') separator = cursor;
|
||||
if (separator == nullptr)
|
||||
return false;
|
||||
const auto remaining = capacity - (separator + 1 - path);
|
||||
if (remaining <= static_cast<size_t>(lstrlenW(name)))
|
||||
return false;
|
||||
lstrcpyW(separator + 1, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
HMODULE load_menu_hook() {
|
||||
wchar_t path[4096] = {};
|
||||
return helper_file_path(path, std::size(path), L"wine-access-menu.dll")
|
||||
? LoadLibraryW(path) : nullptr;
|
||||
}
|
||||
|
||||
HANDLE start_menu_host32(HWND bridge) {
|
||||
wchar_t path[4096] = {};
|
||||
if (!helper_file_path(path, std::size(path), L"wine-access-menu-host32.exe"))
|
||||
return nullptr;
|
||||
const auto remaining = std::size(path) - (separator + 1 - path);
|
||||
if (remaining <= static_cast<size_t>(lstrlenW(L"wine-access-menu.dll")))
|
||||
wchar_t command[8192] = {};
|
||||
wsprintfW(command, L"\"%s\" %lu %lu", path, GetCurrentProcessId(),
|
||||
static_cast<DWORD>(reinterpret_cast<UINT_PTR>(bridge)));
|
||||
STARTUPINFOW startup{};
|
||||
startup.cb = sizeof(startup);
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path, command, nullptr, nullptr, FALSE, 0, nullptr,
|
||||
nullptr, &startup, &process))
|
||||
return nullptr;
|
||||
lstrcpyW(separator + 1, L"wine-access-menu.dll");
|
||||
return LoadLibraryW(path);
|
||||
CloseHandle(process.hThread);
|
||||
return process.hProcess;
|
||||
}
|
||||
|
||||
void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||
@@ -650,6 +716,21 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||
return;
|
||||
if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
|
||||
return;
|
||||
if (HWND combo = combo_event_control(window, event)) {
|
||||
// The combo's internal list can emit focus events even while the combo
|
||||
// retains keyboard focus. Keep a single source for focus and value updates.
|
||||
GUITHREADINFO info{};
|
||||
info.cbSize = sizeof(info);
|
||||
HWND foreground = GetForegroundWindow();
|
||||
if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) &&
|
||||
!(info.flags & GUI_INMENUMODE) && info.hwndFocus == combo) {
|
||||
if (event == EVENT_OBJECT_FOCUS && window == combo)
|
||||
present_standard_focus(combo);
|
||||
else
|
||||
poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE);
|
||||
}
|
||||
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) || is_native_item_control(window)) {
|
||||
@@ -920,6 +1001,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
HHOOK inputHook = nullptr;
|
||||
HMODULE menuModule = nullptr;
|
||||
HWND menuBridge = nullptr;
|
||||
HANDLE menuHost32 = nullptr;
|
||||
if (dialogReaderEnabled) {
|
||||
hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0,
|
||||
0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
|
||||
@@ -943,10 +1025,16 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
if (menuHook == nullptr)
|
||||
std::fprintf(stderr, "wine-access: native menu hook unavailable (error %lu)\n",
|
||||
static_cast<unsigned long>(GetLastError()));
|
||||
if (menuBridge != nullptr) {
|
||||
menuHost32 = start_menu_host32(menuBridge);
|
||||
if (menuHost32 == nullptr)
|
||||
std::fprintf(stderr, "wine-access: 32-bit menu host unavailable (error %lu)\n",
|
||||
static_cast<unsigned long>(GetLastError()));
|
||||
}
|
||||
}
|
||||
MSG message{};
|
||||
// Native lists, tabs and trees can omit selection WinEvents in Wine. Observe
|
||||
// the focused item with scalar queries; retrieve text only when it changes.
|
||||
// Native lists, tabs, trees and combo boxes can omit change WinEvents in Wine.
|
||||
// Observe only the foreground control; never read background selections.
|
||||
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 &&
|
||||
@@ -966,6 +1054,15 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
UnhookWindowsHookEx(inputHook);
|
||||
if (menuBridge != nullptr)
|
||||
DestroyWindow(menuBridge);
|
||||
if (menuHost32 != nullptr) {
|
||||
// Destroying the bridge ends the host's loop. Parent-process death also
|
||||
// stops it, so a reader crash cannot leave an orphaned global menu hook.
|
||||
if (WaitForSingleObject(menuHost32, 1000) == WAIT_TIMEOUT) {
|
||||
TerminateProcess(menuHost32, 0);
|
||||
WaitForSingleObject(menuHost32, 1000);
|
||||
}
|
||||
CloseHandle(menuHost32);
|
||||
}
|
||||
if (menuModule != nullptr)
|
||||
FreeLibrary(menuModule);
|
||||
if (controllerActive) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "menu_bridge.h"
|
||||
#ifndef WINE_ACCESS_MENU_ONLY
|
||||
#include "edit_hook.h"
|
||||
#endif
|
||||
|
||||
static void menu_text(WCHAR *output, UINT capacity, const WCHAR *input) {
|
||||
UINT used = 0;
|
||||
@@ -20,7 +22,9 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp,
|
||||
LPARAM lp) {
|
||||
if (code >= 0) {
|
||||
const CWPSTRUCT *message = (const CWPSTRUCT *)lp;
|
||||
#ifndef WINE_ACCESS_MENU_ONLY
|
||||
if (message->message == WM_SETFOCUS) wine_access_watch_edit(message->hwnd);
|
||||
#endif
|
||||
if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP) {
|
||||
HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL);
|
||||
HWND owner = GetAncestor(message->hwnd, GA_ROOT);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include "menu_bridge.h"
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static DWORD foreground_thread32(void) {
|
||||
DWORD pid = 0;
|
||||
DWORD thread = GetWindowThreadProcessId(GetForegroundWindow(), &pid);
|
||||
HANDLE process = pid ? OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) : NULL;
|
||||
BOOL wow64 = FALSE;
|
||||
BOOL known = process && IsWow64Process(process, &wow64);
|
||||
if (process) CloseHandle(process);
|
||||
return known && wow64 ? thread : 0;
|
||||
}
|
||||
|
||||
// Hook DLLs must match the application's architecture. This small PE host
|
||||
// supplies the 32-bit menu hook without requiring 32-bit Linux GLib/Winelib.
|
||||
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
|
||||
(void)instance; (void)previous; (void)show;
|
||||
char *end;
|
||||
DWORD parentId = strtoul(args, &end, 10);
|
||||
HWND bridge = (HWND)(UINT_PTR)strtoul(end, &end, 10);
|
||||
DWORD bridgeProcess = 0;
|
||||
GetWindowThreadProcessId(bridge, &bridgeProcess);
|
||||
if (!parentId || bridgeProcess != parentId) return 1;
|
||||
HANDLE parent = OpenProcess(SYNCHRONIZE, FALSE, parentId);
|
||||
if (!parent) return 1;
|
||||
|
||||
WCHAR path[4096] = {0};
|
||||
DWORD length = GetModuleFileNameW(NULL, path, 4096);
|
||||
WCHAR *name = NULL;
|
||||
for (DWORD i = 0; i < length && i < 4096; ++i)
|
||||
if (path[i] == L'\\' || path[i] == L'/') name = path + i + 1;
|
||||
if (!length || length >= 4096 || !name ||
|
||||
name - path + lstrlenW(L"wine-access-menu32.dll") >= 4096) {
|
||||
CloseHandle(parent);
|
||||
return 1;
|
||||
}
|
||||
lstrcpyW(name, L"wine-access-menu32.dll");
|
||||
HMODULE module = LoadLibraryW(path);
|
||||
HOOKPROC callback = module ? (HOOKPROC)GetProcAddress(module, "WineAccessMenuHook") : NULL;
|
||||
HHOOK hook = NULL;
|
||||
DWORD hookedThread = 0;
|
||||
if (!callback)
|
||||
fprintf(stderr, "wine-access: 32-bit menu hook unavailable (error %lu)\n", GetLastError());
|
||||
if (callback) {
|
||||
while (IsWindow(bridge)) {
|
||||
// Wine's mixed-architecture global hook chain can suppress the 64-bit
|
||||
// hook. Attach only to the foreground 32-bit UI thread, and release it
|
||||
// when focus moves elsewhere. The 64-bit helper keeps its global hook.
|
||||
DWORD thread = foreground_thread32();
|
||||
if (thread != hookedThread) {
|
||||
if (hook) UnhookWindowsHookEx(hook);
|
||||
hook = thread ? SetWindowsHookExW(WH_CALLWNDPROC, callback, module, thread) : NULL;
|
||||
hookedThread = hook ? thread : 0;
|
||||
}
|
||||
DWORD result = MsgWaitForMultipleObjects(1, &parent, FALSE, 100, QS_ALLINPUT);
|
||||
if (result == WAIT_OBJECT_0 || result == WAIT_FAILED) break;
|
||||
MSG message;
|
||||
while (PeekMessageW(&message, NULL, 0, 0, PM_REMOVE)) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
}
|
||||
if (hook) UnhookWindowsHookEx(hook);
|
||||
}
|
||||
if (module) FreeLibrary(module);
|
||||
CloseHandle(parent);
|
||||
return callback ? 0 : 1;
|
||||
}
|
||||
+34
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Build the project, then run `python3 tests/menu_integration.py --build-dir build`
|
||||
from the repository root in a graphical session. Dependencies are Wine,
|
||||
MinGW's x86_64 C compiler, PyGObject, and `dbus-run-session`.
|
||||
MinGW's x86_64 and i686 C compilers, PyGObject, and `dbus-run-session`.
|
||||
|
||||
The test creates a temporary Wine prefix and a private D-Bus service. Its
|
||||
64-bit PE application exercises the separately running Winelib helper's
|
||||
@@ -108,6 +108,39 @@ reader's `docs/testing/wine-accessibility.md` for current evidence and live
|
||||
acceptance gates. Run Xvfb fixtures sequentially; concurrent automatic display
|
||||
allocation can collide.
|
||||
|
||||
## GoldWave menus and combo boxes
|
||||
|
||||
The helper supplies both 32-bit and 64-bit menu hooks. Check both architectures:
|
||||
|
||||
```sh
|
||||
xvfb-run -a python3 tests/menu_integration.py --fixture menu
|
||||
xvfb-run -a python3 tests/menu_integration.py --fixture menu --32-bit
|
||||
xvfb-run -a python3 tests/menu_integration.py --fixture controls
|
||||
xvfb-run -a python3 tests/menu_integration.py --fixture controls --32-bit
|
||||
xvfb-run -a python3 tests/menu_integration.py --fixture goldwave --goldwave-exe /path/to/GoldWave.exe
|
||||
```
|
||||
|
||||
The controls fixture changes a combo selection without emitting a value
|
||||
WinEvent. It requires the new value while focus stays on the combo, with the
|
||||
same source identity as its focus event. This catches both missing polling and
|
||||
Wine's misleading focus notifications for the combo's internal list.
|
||||
|
||||
The optional GoldWave 5.70 fixture copies the application directory into a
|
||||
temporary directory and uses a fresh Wine prefix with Force screen reader
|
||||
mode enabled. It waits for the actual main form, dismisses the first-run
|
||||
settings prompt in the test copy, and drives Alt+F, menu arrows, Ctrl+O and
|
||||
file-type combo Down/Down/Up using SendInput. On October 11 it received File,
|
||||
New, Open and unavailable Close menu events, followed by Wave/MPEG Audio/Wave
|
||||
value updates without tabbing away. This confirms helper events through the
|
||||
private D-Bus recorder; audible speech and physical braille need live checks.
|
||||
|
||||
Install all five helper artifacts together, restart Cthulhu, and reopen
|
||||
GoldWave. With screen reader mode enabled, check File and other menus with
|
||||
Alt and arrows, then Escape back to the previous control. In Open Sound,
|
||||
check Files of type and Look in with arrows: expect the new value immediately
|
||||
in speech and braille, with keyboard focus remaining on the combo. Repeat
|
||||
Tab/Shift+Tab and Alt+Down/Escape dropdown opening and closing.
|
||||
|
||||
## Notepad++ / Scintilla checks
|
||||
|
||||
Use a locally available 64-bit Notepad++ executable:
|
||||
|
||||
@@ -28,7 +28,7 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
case 10: focus_control(combo); break;
|
||||
case 11:
|
||||
SendMessageW(combo, CB_SETCURSEL, 1, 0);
|
||||
NotifyWinEvent(EVENT_OBJECT_VALUECHANGE, combo, OBJID_CLIENT, CHILDID_SELF);
|
||||
// Common file dialogs can change the selection without a WinEvent.
|
||||
break;
|
||||
case 12: focus_control(edit); break;
|
||||
case 13: focus_control(atlTabs); break;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
// Drive a disposable copy of GoldWave 5.70 using real Wine keyboard input.
|
||||
#include <windows.h>
|
||||
#include <dlgs.h>
|
||||
|
||||
static DWORD appPid;
|
||||
static HWND app, dialog;
|
||||
|
||||
static BOOL CALLBACK dismiss_setup(HWND window, LPARAM unused) {
|
||||
(void)unused;
|
||||
WCHAR text[64] = {0};
|
||||
GetWindowTextW(window, text, 64);
|
||||
if (!lstrcmpW(text, L"No") || !lstrcmpW(text, L"&No"))
|
||||
PostMessageW(window, BM_CLICK, 0, 0);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static BOOL CALLBACK find_window(HWND window, LPARAM unused) {
|
||||
(void)unused;
|
||||
DWORD pid = 0;
|
||||
WCHAR title[128] = {0};
|
||||
GetWindowThreadProcessId(window, &pid);
|
||||
GetWindowTextW(window, title, 128);
|
||||
if (pid == appPid && IsWindowVisible(window)) {
|
||||
WCHAR cls[128] = {0};
|
||||
GetClassNameW(window, cls, 128);
|
||||
if (!lstrcmpW(cls, L"TGWMainForm")) app = window;
|
||||
if (GetDlgItem(window, cmb1)) dialog = window;
|
||||
if (!lstrcmpW(cls, L"TMessageForm")) EnumChildWindows(window, dismiss_setup, 0);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void key(WORD vk, BOOL alt, BOOL control) {
|
||||
INPUT input[6] = {0};
|
||||
UINT count = 0;
|
||||
if (alt) { input[count].ki.wVk = VK_MENU; ++count; }
|
||||
if (control) { input[count].ki.wVk = VK_CONTROL; ++count; }
|
||||
input[count++].ki.wVk = vk;
|
||||
input[count].ki.wVk = vk; input[count++].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
if (control) { input[count].ki.wVk = VK_CONTROL; input[count++].ki.dwFlags = KEYEVENTF_KEYUP; }
|
||||
if (alt) { input[count].ki.wVk = VK_MENU; input[count++].ki.dwFlags = KEYEVENTF_KEYUP; }
|
||||
for (UINT i = 0; i < count; ++i) input[i].type = INPUT_KEYBOARD;
|
||||
if (SendInput(count, input, sizeof(INPUT)) != count) ExitProcess(3);
|
||||
Sleep(400);
|
||||
}
|
||||
|
||||
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
|
||||
(void)instance; (void)previous; (void)args; (void)show;
|
||||
// The runner supplies a fresh prefix; this setting never touches the user's
|
||||
// configuration. Match the reported Force screen reader mode setup.
|
||||
HKEY options;
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\GoldWave\\GoldWave\\Options", 0,
|
||||
NULL, 0, KEY_SET_VALUE, NULL, &options, NULL) != ERROR_SUCCESS) return 2;
|
||||
DWORD startupOptions = 0x3008;
|
||||
RegSetValueExW(options, L"StartupOptions", 0, REG_DWORD, (BYTE *)&startupOptions, sizeof(startupOptions));
|
||||
RegCloseKey(options);
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\GoldWave\\GoldWave", 0,
|
||||
NULL, 0, KEY_SET_VALUE, NULL, &options, NULL) != ERROR_SUCCESS) return 2;
|
||||
const WCHAR version[] = L"5.70";
|
||||
RegSetValueExW(options, L"Version", 0, REG_SZ, (const BYTE *)version, sizeof(version));
|
||||
RegCloseKey(options);
|
||||
WCHAR executable[4096] = {0};
|
||||
if (!GetEnvironmentVariableW(L"WINE_ACCESS_GOLDWAVE_EXE", executable, 4096)) return 2;
|
||||
STARTUPINFOW startup = {0};
|
||||
startup.cb = sizeof(startup);
|
||||
PROCESS_INFORMATION process = {0};
|
||||
if (!CreateProcessW(executable, NULL, NULL, NULL, FALSE, 0, NULL, NULL, &startup, &process)) return 2;
|
||||
appPid = process.dwProcessId;
|
||||
for (int i = 0; i < 200 && !app; ++i) { EnumWindows(find_window, 0); Sleep(50); }
|
||||
if (!app) return 2;
|
||||
SetForegroundWindow(app);
|
||||
Sleep(500);
|
||||
key('F', TRUE, FALSE);
|
||||
key(VK_DOWN, FALSE, FALSE);
|
||||
key(VK_DOWN, FALSE, FALSE);
|
||||
key(VK_ESCAPE, FALSE, FALSE);
|
||||
key(VK_ESCAPE, FALSE, FALSE);
|
||||
key('O', FALSE, TRUE);
|
||||
for (int i = 0; i < 100 && !dialog; ++i) { EnumWindows(find_window, 0); Sleep(50); }
|
||||
if (!dialog) return 2;
|
||||
HWND combo = GetDlgItem(dialog, cmb1);
|
||||
SendMessageW(dialog, WM_NEXTDLGCTL, (WPARAM)combo, TRUE);
|
||||
Sleep(500);
|
||||
WCHAR before[512] = {0}, after[512] = {0};
|
||||
SendMessageW(combo, WM_GETTEXT, 512, (LPARAM)before);
|
||||
key(VK_DOWN, FALSE, FALSE);
|
||||
SendMessageW(combo, WM_GETTEXT, 512, (LPARAM)after);
|
||||
if (!lstrcmpW(before, after)) return 4;
|
||||
GUITHREADINFO info = {0};
|
||||
info.cbSize = sizeof(info);
|
||||
GetGUIThreadInfo(GetWindowThreadProcessId(dialog, NULL), &info);
|
||||
if (info.hwndFocus != combo) return 4;
|
||||
key(VK_DOWN, FALSE, FALSE);
|
||||
key(VK_UP, FALSE, FALSE);
|
||||
key(VK_ESCAPE, FALSE, FALSE);
|
||||
PostMessageW(app, WM_CLOSE, 0, 0);
|
||||
int result = WaitForSingleObject(process.hProcess, 3000) == WAIT_OBJECT_0 ? 0 : 2;
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
return result;
|
||||
}
|
||||
@@ -17,24 +17,28 @@ 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", "list", "delphi", "winforms", "controls", "scintilla"), default="menu")
|
||||
parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu")
|
||||
parser.add_argument("--notepadpp-exe", type=Path)
|
||||
parser.add_argument("--goldwave-exe", type=Path)
|
||||
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 not in ("list", "delphi", "controls"):
|
||||
parser.error("32-bit checks currently require --fixture list, delphi or controls")
|
||||
if args.is32Bit and args.fixture not in ("menu", "list", "delphi", "controls"):
|
||||
parser.error("32-bit checks currently require --fixture menu, list, delphi or controls")
|
||||
if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"):
|
||||
parser.error("helper lifecycle checks require --fixture edit, winforms or scintilla")
|
||||
if args.fixture == "scintilla" and (not args.notepadpp_exe or not args.notepadpp_exe.is_file()):
|
||||
parser.error("--fixture scintilla requires --notepadpp-exe pointing to a 64-bit Notepad++ executable")
|
||||
if args.fixture == "goldwave" and (not args.goldwave_exe or not args.goldwave_exe.is_file()):
|
||||
parser.error("--fixture goldwave requires --goldwave-exe pointing to GoldWave 5.70")
|
||||
build = Path(args.build_dir).resolve()
|
||||
if not args.session:
|
||||
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
|
||||
__file__, "--session", "--build-dir", str(build),
|
||||
"--fixture", args.fixture,
|
||||
*(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe else []),
|
||||
*(["--goldwave-exe", str(args.goldwave_exe.resolve())] if args.goldwave_exe else []),
|
||||
*(["--32-bit"] if args.is32Bit else []),
|
||||
*(["--late-helper"] if args.late_helper else []),
|
||||
*(["--restart-helper"] if args.restart_helper else [])]))
|
||||
@@ -60,10 +64,14 @@ connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
||||
env = os.environ.copy()
|
||||
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all",
|
||||
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG=os.environ.get("WINE_ACCESS_TEST_DEBUG", "-all"),
|
||||
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
|
||||
ready = Path(temporary) / "ready"
|
||||
env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\")
|
||||
if args.fixture == "goldwave":
|
||||
appDir = Path(temporary) / "goldwave"
|
||||
shutil.copytree(args.goldwave_exe.parent, appDir)
|
||||
env["WINE_ACCESS_GOLDWAVE_EXE"] = "Z:" + str(appDir / args.goldwave_exe.name).replace("/", "\\")
|
||||
if args.fixture == "scintilla":
|
||||
# Copy the application to isolate its configuration as well as its
|
||||
# document. Never open or edit files in the user's running instance.
|
||||
@@ -80,7 +88,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
||||
env["WINE_ACCESS_NOTEPADPP_EXE"] = "Z:" + str(appDir / args.notepadpp_exe.name).replace("/", "\\")
|
||||
env["WINE_ACCESS_SCINTILLA_DOCUMENT"] = "Z:" + str(document).replace("/", "\\")
|
||||
fixture = Path(temporary) / "menu-fixture.exe"
|
||||
compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc"
|
||||
compiler = "i686-w64-mingw32-gcc" if args.is32Bit or args.fixture == "goldwave" else "x86_64-w64-mingw32-gcc"
|
||||
sourceName = "edit" if args.fixture == "winforms" else args.fixture
|
||||
if args.fixture == "delphi":
|
||||
sourceName = "list"
|
||||
@@ -88,7 +96,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
||||
*(["-DWINE_ACCESS_WINFORMS_FIXTURE"] if args.fixture == "winforms" else []),
|
||||
*(["-DWINE_ACCESS_DELPHI_FIXTURE"] if args.fixture == "delphi" else []),
|
||||
"-o", str(fixture), str(Path(__file__).with_name(sourceName + "_fixture.c")),
|
||||
"-luser32", "-lcomctl32", "-loleacc", "-lole32"], check=True)
|
||||
"-luser32", "-lcomctl32", "-loleacc", "-lole32", "-ladvapi32"], check=True)
|
||||
helper = None
|
||||
app = None
|
||||
try:
|
||||
@@ -121,7 +129,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
||||
restarted = True
|
||||
time.sleep(0.01)
|
||||
assert app.poll() == 0, "fixture failed or timed out"
|
||||
assert helper.poll() is None, "helper exited during the fixture"
|
||||
assert helper.poll() is None, f"helper exited during the fixture: {helper.returncode}"
|
||||
if args.restart_helper:
|
||||
assert restarted, "fixture never triggered a helper restart"
|
||||
for _ in range(20):
|
||||
@@ -134,6 +142,18 @@ 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}"
|
||||
elif event[1] == "value" and event[3] == "combo box":
|
||||
assert event[0] == focused, f"combo value sent for an unfocused source: {event!r}; focus={focused}; events={events!r}"
|
||||
if args.fixture == "goldwave":
|
||||
for event in events:
|
||||
print(event[1:9])
|
||||
menus = [e for e in events if ":menu:" in e[0]]
|
||||
assert any(e[2] == "File" and e[3] == "submenu" for e in menus), "GoldWave File menu missing"
|
||||
assert any(e[2].startswith("Open") and e[3] == "menu item" for e in menus), "GoldWave Open command missing"
|
||||
values = [e[4] for e in events if e[1] == "value" and e[3] == "combo box"]
|
||||
assert len(values) >= 3 and len(set(values)) >= 2, f"GoldWave combo arrow feedback missing: {values!r}"
|
||||
print("PASS: actual 32-bit GoldWave menus and file-type combo arrows without tabbing away")
|
||||
sys.exit(0)
|
||||
if args.fixture == "controls":
|
||||
for event in events:
|
||||
print(event[1:9])
|
||||
|
||||
Reference in new issue
Block a user