diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f3324e..31736e5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}") diff --git a/README.md b/README.md index a49e38e..cf40a70 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.sh b/build.sh index 2edc9ac..43381b7 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/distro-packages/ArchLinux/wine-access-git/README.md b/distro-packages/ArchLinux/wine-access-git/README.md index 7bd8eb2..e8908e5 100644 --- a/distro-packages/ArchLinux/wine-access-git/README.md +++ b/distro-packages/ArchLinux/wine-access-git/README.md @@ -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. diff --git a/main.cpp b/main.cpp index db588ba..8d77cbd 100644 --- a/main.cpp +++ b/main.cpp @@ -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(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(lstrlenW(L"wine-access-menu.dll"))) + wchar_t command[8192] = {}; + wsprintfW(command, L"\"%s\" %lu %lu", path, GetCurrentProcessId(), + static_cast(reinterpret_cast(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(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(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) { diff --git a/menu_hook.c b/menu_hook.c index 7967e6c..a0ffda0 100644 --- a/menu_hook.c +++ b/menu_hook.c @@ -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); diff --git a/menu_host32.c b/menu_host32.c new file mode 100644 index 0000000..4d650e6 --- /dev/null +++ b/menu_host32.c @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "menu_bridge.h" +#include +#include + +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; +} diff --git a/tests/README.md b/tests/README.md index e96592b..49a750e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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: diff --git a/tests/controls_fixture.c b/tests/controls_fixture.c index fa01519..0b55cef 100644 --- a/tests/controls_fixture.c +++ b/tests/controls_fixture.c @@ -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; diff --git a/tests/goldwave_fixture.c b/tests/goldwave_fixture.c new file mode 100644 index 0000000..da45799 --- /dev/null +++ b/tests/goldwave_fixture.c @@ -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 +#include + +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; +} diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 11cfb72..e88e65f 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -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])