From 0992ffee0dc30060bed2fcc57cfaf89c919c6fe5 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 9 Oct 2026 15:45:18 -0400 Subject: [PATCH] First pass at handling menus in wine. --- CMakeLists.txt | 14 ++ README.md | 32 +++- .../ArchLinux/wine-access-git/PKGBUILD | 2 +- main.cpp | 162 ++++++++++++++++-- menu_bridge.h | 21 +++ menu_hook.c | 68 ++++++++ tests/README.md | 33 ++++ tests/menu_fixture.c | 106 ++++++++++++ tests/menu_integration.py | 100 +++++++++++ 9 files changed, 520 insertions(+), 18 deletions(-) create mode 100644 menu_bridge.h create mode 100644 menu_hook.c create mode 100644 tests/README.md create mode 100644 tests/menu_fixture.c create mode 100644 tests/menu_integration.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 427a81f..013158d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,18 @@ set(WINE_ACCESS_INSTALL_DIR "${CMAKE_INSTALL_LIBEXECDIR}/cthulhu/wine" CACHE PAT 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) + +# A PE hook DLL can be loaded into ordinary Wine applications without any +# GLib dependencies. Keep it alongside the Winelib helper. +set(menu_hook "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu.dll") +add_custom_command(OUTPUT "${menu_hook}" + COMMAND "${MINGW_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared + -static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" + -luser32 + DEPENDS menu_hook.c menu_bridge.h + VERBATIM) +add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}") set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") set(generated_header "${generated_dir}/nvdaController.h") @@ -21,6 +33,7 @@ add_custom_command( VERBATIM) add_executable(cthulhu-wine-access main.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}") target_link_libraries(cthulhu-wine-access PRIVATE @@ -35,4 +48,5 @@ target_compile_options(cthulhu-wine-access PRIVATE install(FILES "${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe" "${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so" + "${menu_hook}" DESTINATION "${WINE_ACCESS_INSTALL_DIR}") diff --git a/README.md b/README.md index e519fff..9576501 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ Building requires an x86_64 Linux host with: - CMake 3.20 or newer; - `pkg-config` and the GLib/GIO development files; -- Wine development tools: `winegcc`, `wineg++`, and `widl`. +- Wine development tools: `winegcc`, `wineg++`, and `widl`; +- `x86_64-w64-mingw32-gcc` to build the native menu hook DLL + (`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. @@ -50,6 +52,7 @@ That installs the helper and its Winelib sidecar as: ```text $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 ``` For Cthulhu-Go, either set `CTHULHU_WINE_ACCESS_HELPER` to the installed @@ -76,6 +79,33 @@ 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 +`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`. + +Keep all three installed files together. The native hook currently targets +64-bit applications; 32-bit menu coverage and custom menus that do not send +`WM_MENUSELECT` are not established. + +## Menu integration check + +After building, run this in a graphical session with Python's PyGObject +available (`python-gobject` on Arch Linux): + +```sh +python3 tests/menu_integration.py --build-dir build +``` + +The test opens a short-lived native menu window in a temporary Wine prefix, +uses a private D-Bus recording service, and checks keyboard menu navigation, +context menus, shortcuts, item states, and duplicate suppression. It does not +send speech or braille to the running reader. Live listening with Cthulhu is a +separate acceptance check. + ## License The helper source is available under the terms of LGPL-2.1-or-later. See diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index 306c025..33fa252 100644 --- a/distro-packages/ArchLinux/wine-access-git/PKGBUILD +++ b/distro-packages/ArchLinux/wine-access-git/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64') url='https://git.stormux.org/storm/wine-access' license=('LGPL-2.1-or-later') depends=('glib2' 'wine') -makedepends=('cmake' 'git' 'pkgconf') +makedepends=('cmake' 'git' 'pkgconf' 'mingw-w64-gcc') provides=('wine-access') conflicts=('wine-access') # Wine's import generation needs native object symbols, which LTO hides. diff --git a/main.cpp b/main.cpp index 67a3d17..0036a81 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "nvdaController.h" +#include "menu_bridge.h" #include #include @@ -9,8 +10,10 @@ #include #include +#include #include -#include +#include +#include #include #include @@ -20,6 +23,21 @@ constexpr auto OBJECT_PATH = "/org/stormux/Cthulhu1/Service"; constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service"; constexpr int DBUS_TIMEOUT_MS = 500; GDBusConnection *connection = nullptr; +std::deque menuEvents; +std::string lastMenuEvent; +ULONGLONG lastMenuOwner = 0; + +// 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. +bool wide_contains(const wchar_t *text, const wchar_t *needle) { + for (; *text != L'\0'; ++text) { + const wchar_t *left = text; + const wchar_t *right = needle; + while (*right != L'\0' && *left == *right) { ++left; ++right; } + if (*right == L'\0') return true; + } + return false; +} struct ControlDescription { std::string name; @@ -139,7 +157,7 @@ void find_control_label(HWND window, wchar_t *text, int capacity) { if (previous == nullptr || !GetClassNameW(previous, className, std::size(className))) return; - if (wcscmp(className, L"Static") != 0 && wcscmp(className, L"Button") != 0) + if (lstrcmpW(className, L"Static") != 0 && lstrcmpW(className, L"Button") != 0) continue; if (get_control_text(previous, text, capacity)) tidy_text(text, true); @@ -181,7 +199,7 @@ ControlDescription describe_standard_control(HWND window) { return control; get_control_text(window, text, std::size(text)); - if (wcscmp(className, L"Button") == 0) { + if (lstrcmpW(className, L"Button") == 0) { control.name = to_utf8(text); const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); DWORD_PTR checked = 0; @@ -213,10 +231,10 @@ ControlDescription describe_standard_control(HWND window) { return control; } - const bool isEdit = wcscmp(className, L"Edit") == 0 || - wcscmp(className, L"RICHEDIT50W") == 0 || - wcscmp(className, L"RichEdit20W") == 0 || - wcscmp(className, L"RichEdit20A") == 0; + const bool isEdit = lstrcmpW(className, L"Edit") == 0 || + lstrcmpW(className, L"RICHEDIT50W") == 0 || + lstrcmpW(className, L"RichEdit20W") == 0 || + lstrcmpW(className, L"RichEdit20A") == 0; if (isEdit) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); @@ -229,8 +247,8 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (wcscmp(className, L"ComboBox") == 0 || - wcscmp(className, L"ComboBoxEx32") == 0) { + if (lstrcmpW(className, L"ComboBox") == 0 || + lstrcmpW(className, L"ComboBoxEx32") == 0) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "combo box"; @@ -238,8 +256,8 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (wcscmp(className, L"ListBox") == 0 || - wcscmp(className, L"ComboLBox") == 0) { + if (lstrcmpW(className, L"ListBox") == 0 || + lstrcmpW(className, L"ComboLBox") == 0) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "list"; @@ -271,7 +289,7 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (wcscmp(className, L"msctls_trackbar32") == 0) { + if (lstrcmpW(className, L"msctls_trackbar32") == 0) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "slider"; @@ -282,7 +300,7 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (wcscmp(className, L"Static") == 0) { + if (lstrcmpW(className, L"Static") == 0) { control.name = to_utf8(text); control.role = "static text"; return control; @@ -301,6 +319,92 @@ bool present_standard_focus(HWND window) { control, window_title(window)); } +void present_menu_event(const WineAccessMenuEvent &event) { + HWND owner = reinterpret_cast(static_cast(event.owner)); + if (owner == nullptr) + return; + if (event.flags & WINE_ACCESS_MENU_CLOSED) { + if (!lastMenuEvent.empty() && event.owner == lastMenuOwner) { + lastMenuEvent.clear(); + lastMenuOwner = 0; + GUITHREADINFO info{}; + info.cbSize = sizeof(info); + if (owner == GetAncestor(GetForegroundWindow(), GA_ROOT) && + GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) && + info.hwndFocus != nullptr) + present_standard_focus(info.hwndFocus); + } + return; + } + if (owner != GetAncestor(GetForegroundWindow(), GA_ROOT)) + return; + ControlDescription control; + control.name = to_utf8(event.name); + control.role = event.flags & WINE_ACCESS_MENU_SUBMENU ? "submenu" : "menu item"; + control.value = to_utf8(event.shortcut); + if (event.flags & WINE_ACCESS_MENU_CHECKED) + control.description = "checked"; + if (event.flags & WINE_ACCESS_MENU_DISABLED) { + if (!control.description.empty()) control.description += ", "; + control.description += "unavailable"; + } + const std::string id = std::to_string(event.owner) + ":menu:" + + std::to_string(event.menu) + ":" + std::to_string(event.item); + const std::string key = id + "\n" + control.name + "\n" + control.value + + "\n" + std::to_string(event.flags); + if (key == lastMenuEvent) + return; + if (present_accessible_event(id, "focus", control, to_utf8(event.title))) { + lastMenuEvent = key; + lastMenuOwner = event.owner; + } +} + +LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_COPYDATA && lp != 0) { + const auto *data = reinterpret_cast(lp); + if (data->dwData != WINE_ACCESS_MENU_TAG || data->cbData != sizeof(WineAccessMenuEvent) || + data->lpData == nullptr || menuEvents.size() >= 32) + return FALSE; + auto event = *static_cast(data->lpData); + event.name[std::size(event.name) - 1] = L'\0'; + event.shortcut[std::size(event.shortcut) - 1] = L'\0'; + event.title[std::size(event.title) - 1] = L'\0'; + menuEvents.push_back(event); + if (!PostMessageW(window, WINE_ACCESS_MENU_MESSAGE, 0, 0)) { + menuEvents.pop_back(); + return FALSE; + } + return TRUE; + } + if (message == WINE_ACCESS_MENU_MESSAGE) { + if (!menuEvents.empty()) { + const auto event = menuEvents.front(); + menuEvents.pop_front(); + present_menu_event(event); + } + return 0; + } + 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; + wchar_t *separator = nullptr; + for (wchar_t *cursor = path; *cursor; ++cursor) + if (*cursor == L'\\' || *cursor == L'/') separator = cursor; + if (separator == nullptr) + return nullptr; + const auto remaining = std::size(path) - (separator + 1 - path); + if (remaining <= static_cast(lstrlenW(L"wine-access-menu.dll"))) + return nullptr; + lstrcpyW(separator + 1, L"wine-access-menu.dll"); + return LoadLibraryW(path); +} + void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, LONG objectId, LONG childId, DWORD, DWORD) { if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE && @@ -487,9 +591,9 @@ extern "C" error_status_t __stdcall isSpeaking(boolean *speaking) { int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { const wchar_t *commandLine = GetCommandLineW(); const bool dialogReaderEnabled = - wcsstr(commandLine, L"--no-dialog-reader") == nullptr; + !wide_contains(commandLine, L"--no-dialog-reader"); const bool controllerEnabled = - wcsstr(commandLine, L"--no-controller") == nullptr; + !wide_contains(commandLine, L"--no-controller"); if (!dialogReaderEnabled && !controllerEnabled) return 0; @@ -534,9 +638,29 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { } HWINEVENTHOOK hook = nullptr; - if (dialogReaderEnabled) + HHOOK menuHook = nullptr; + HMODULE menuModule = nullptr; + HWND menuBridge = nullptr; + if (dialogReaderEnabled) { hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + WNDCLASSW bridgeClass{}; + bridgeClass.lpfnWndProc = menu_bridge_proc; + bridgeClass.hInstance = instance; + bridgeClass.lpszClassName = WINE_ACCESS_MENU_CLASS; + RegisterClassW(&bridgeClass); + menuBridge = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + WINE_ACCESS_MENU_CLASS, L"", 0, 0, 0, 0, 0, + nullptr, nullptr, instance, nullptr); + if (menuBridge != nullptr && (menuModule = load_menu_hook()) != nullptr) { + const auto callback = std::bit_cast(GetProcAddress(menuModule, "WineAccessMenuHook")); + if (callback != nullptr) + menuHook = SetWindowsHookExW(WH_CALLWNDPROC, callback, menuModule, 0); + } + if (menuHook == nullptr) + std::fprintf(stderr, "wine-access: native menu hook unavailable (error %lu)\n", + static_cast(GetLastError())); + } MSG message{}; while (GetMessageW(&message, nullptr, 0, 0) > 0) { TranslateMessage(&message); @@ -544,6 +668,12 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { } if (hook != nullptr) UnhookWinEvent(hook); + if (menuHook != nullptr) + UnhookWindowsHookEx(menuHook); + if (menuBridge != nullptr) + DestroyWindow(menuBridge); + if (menuModule != nullptr) + FreeLibrary(menuModule); if (controllerActive) { reset_rpc_server(); } diff --git a/menu_bridge.h b/menu_bridge.h new file mode 100644 index 0000000..6f12efc --- /dev/null +++ b/menu_bridge.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include + +#define WINE_ACCESS_MENU_CLASS L"CthulhuWineMenuBridge" +#define WINE_ACCESS_MENU_MESSAGE (WM_APP + 1) +#define WINE_ACCESS_MENU_TAG 0x57414d31 +#define WINE_ACCESS_MENU_CLOSED 1 +#define WINE_ACCESS_MENU_SUBMENU 2 +#define WINE_ACCESS_MENU_CHECKED 4 +#define WINE_ACCESS_MENU_DISABLED 8 + +typedef struct WineAccessMenuEvent { + ULONGLONG owner; + ULONGLONG menu; + UINT item; + UINT flags; + WCHAR name[512]; + WCHAR shortcut[128]; + WCHAR title[256]; +} WineAccessMenuEvent; diff --git a/menu_hook.c b/menu_hook.c new file mode 100644 index 0000000..9d96eaf --- /dev/null +++ b/menu_hook.c @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "menu_bridge.h" + +static void menu_text(WCHAR *output, UINT capacity, const WCHAR *input) { + UINT used = 0; + while (*input && *input != '\t' && used + 1 < capacity) { + if (*input == '&') { + ++input; + if (*input != '&') continue; + } + output[used++] = *input++; + } + output[used] = 0; +} + +// This runs in the application's process: Wine does not expose another +// process's HMENU contents to the standalone reader. +__declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp, + LPARAM lp) { + if (code >= 0) { + const CWPSTRUCT *message = (const CWPSTRUCT *)lp; + if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP) { + HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL); + HWND owner = GetAncestor(message->hwnd, GA_ROOT); + BOOL closed = message->message == WM_EXITMENULOOP || + (!message->lParam && HIWORD(message->wParam) == 0xffff); + if (bridge && (closed || owner == GetAncestor(GetForegroundWindow(), GA_ROOT))) { + WineAccessMenuEvent event = {0}; + UINT flags = HIWORD(message->wParam); + HMENU menu = (HMENU)message->lParam; + event.owner = (ULONGLONG)(UINT_PTR)owner; + event.menu = (ULONGLONG)(UINT_PTR)menu; + event.item = LOWORD(message->wParam); + if (closed) { + event.flags = WINE_ACCESS_MENU_CLOSED; + } else { + WCHAR text[640] = {0}; + MENUITEMINFOW info = {0}; + info.cbSize = sizeof(info); + info.fMask = MIIM_STRING | MIIM_STATE | MIIM_FTYPE | MIIM_SUBMENU; + info.dwTypeData = text; + info.cch = sizeof(text) / sizeof(text[0]); + if (!GetMenuItemInfoW(menu, event.item, (flags & MF_POPUP) != 0, &info) || + (info.fType & MFT_SEPARATOR)) + return CallNextHookEx(NULL, code, wp, lp); + menu_text(event.name, 512, text); + for (UINT i = 0; text[i]; ++i) { + if (text[i] == '\t') { + lstrcpynW(event.shortcut, text + i + 1, 128); + break; + } + } + if (!event.name[0]) return CallNextHookEx(NULL, code, wp, lp); + if (info.hSubMenu) event.flags |= WINE_ACCESS_MENU_SUBMENU; + if (info.fState & MFS_CHECKED) event.flags |= WINE_ACCESS_MENU_CHECKED; + if (info.fState & MFS_DISABLED) event.flags |= WINE_ACCESS_MENU_DISABLED; + GetWindowTextW(owner, event.title, 256); + } + COPYDATASTRUCT data = {WINE_ACCESS_MENU_TAG, sizeof(event), &event}; + DWORD_PTR result; + // The bridge only queues the payload; speech never runs in this hook. + SendMessageTimeoutW(bridge, WM_COPYDATA, (WPARAM)message->hwnd, + (LPARAM)&data, SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result); + } + } + } + return CallNextHookEx(NULL, code, wp, lp); +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d6f2179 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,33 @@ +# Menu bridge integration + +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`. + +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 +global menu hook, rather than calling helper functions inside the fixture. +The test processes carry the managed-helper marker so the running Cthulhu +supervisor does not start an additional helper in the test prefix. + +Checks include actual keyboard navigation through File/Edit menus, a nested +submenu and a context menu; displayed command shortcuts; checked and disabled +items; escaped ampersands; duplicate menu selections and reopening; restored +button focus; and exiting with both reader capabilities disabled. Additional +`WM_MENUSELECT` messages exercise duplicate notifications deterministically. + +The fixture window takes focus and closes automatically. Cleanup stops only +the Wine server for the temporary prefix. The recorded events never reach the +running reader's speech or braille service. + +For live acceptance, rebuild/install the package and restart Cthulhu so it +launches the updated helper. In a 64-bit Wine application, use Alt to reach +File/Edit, arrows to move through commands and submenus, Escape to return, and +Shift+F10 for a context menu where supported. Verify speech and braille contain +the highlighted label, role, displayed shortcut, and checked/unavailable state, +and that normal control reading resumes afterward. Test multiple Wine prefixes +and reader shutdown separately. Automated event recording does not establish +live listening or physical braille acceptance. + +32-bit hook support, entirely custom menus without `WM_MENUSELECT`, and full +object review are outside the current implementation. diff --git a/tests/menu_fixture.c b/tests/menu_fixture.c new file mode 100644 index 0000000..c93c0e1 --- /dev/null +++ b/tests/menu_fixture.c @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include + +static HMENU bar, file, edit, nested, context; +static int step; + +static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_TIMER) { + switch (++step) { + case 1: + // Enter File through the application's actual native menu loop. + PostMessageW(window, WM_SYSCOMMAND, SC_KEYMENU, 'f'); + break; + case 2: case 3: + PostMessageW(window, WM_KEYDOWN, VK_DOWN, 1); + break; + case 4: + PostMessageW(window, WM_KEYDOWN, VK_RIGHT, 1); + break; + case 5: + PostMessageW(window, WM_KEYDOWN, VK_ESCAPE, 1); + break; + case 6: + PostMessageW(window, WM_KEYDOWN, VK_ESCAPE, 1); + break; + case 7: + // Context menus use a separate HMENU, unrelated to the menu bar. + TrackPopupMenu(context, TPM_LEFTALIGN, 200, 200, 0, window, NULL); + break; + case 8: + PostMessageW(window, WM_KEYDOWN, VK_DOWN, 1); + break; + case 9: + PostMessageW(window, WM_KEYDOWN, VK_ESCAPE, 1); + break; + case 10: + AppendMenuW(file, MF_STRING | MF_CHECKED | MF_GRAYED, 104, L"Pinned && &offline\tCtrl+P"); + /* fall through */ + case 11: + // Also exercise checked/disabled items and duplicate notifications. + SendMessageW(window, WM_MENUSELECT, MAKEWPARAM(104, MF_CHECKED | MF_GRAYED), (LPARAM)file); + break; + case 12: + SendMessageW(window, WM_MENUSELECT, MAKEWPARAM(0, 0xffff), 0); + break; + case 13: + SendMessageW(window, WM_MENUSELECT, MAKEWPARAM(104, MF_CHECKED | MF_GRAYED), (LPARAM)file); + break; + case 14: + PostMessageW(window, WM_SYSCOMMAND, SC_KEYMENU, 'f'); + break; + case 15: + // Move from Open to the submenu using ordinary menu navigation. + PostMessageW(window, WM_KEYDOWN, VK_DOWN, 1); + PostMessageW(window, WM_KEYDOWN, VK_DOWN, 1); + PostMessageW(window, WM_KEYDOWN, VK_DOWN, 1); + break; + case 16: + PostMessageW(window, WM_KEYDOWN, VK_RIGHT, 1); + break; + case 17: case 18: case 19: + PostMessageW(window, WM_KEYDOWN, VK_ESCAPE, 1); + break; + default: + DestroyWindow(window); + break; + } + 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; + WNDCLASSW cls = {0}; + cls.hInstance = instance; + cls.lpszClassName = L"WineAccessMenuFixture"; + cls.lpfnWndProc = window_proc; + RegisterClassW(&cls); + bar = CreateMenu(); file = CreatePopupMenu(); edit = CreatePopupMenu(); + nested = CreatePopupMenu(); context = CreatePopupMenu(); + AppendMenuW(file, MF_STRING, 101, L"&Open\tCtrl+O"); + AppendMenuW(file, MF_STRING | MF_CHECKED, 102, L"&Word wrap"); + AppendMenuW(file, MF_STRING | MF_CHECKED | MF_GRAYED, 103, L"Save && &close\tCtrl+S"); + AppendMenuW(nested, MF_STRING, 401, L"&Nested action"); + AppendMenuW(file, MF_POPUP, (UINT_PTR)nested, L"&Favorites"); + AppendMenuW(edit, MF_STRING, 302, L"&Cut\tCtrl+X"); + AppendMenuW(context, MF_STRING, 201, L"&Context action"); + AppendMenuW(bar, MF_POPUP, (UINT_PTR)file, L"&File"); + AppendMenuW(bar, MF_POPUP, (UINT_PTR)edit, L"&Edit"); + HWND window = CreateWindowW(cls.lpszClassName, L"Wine menu fixture", WS_OVERLAPPEDWINDOW, + 120, 120, 400, 240, NULL, bar, instance, NULL); + ShowWindow(window, SW_SHOW); + HWND button = CreateWindowW(L"Button", L"Return to control", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 10, 200, 30, window, NULL, instance, NULL); + SetForegroundWindow(window); + SetFocus(button); + SetTimer(window, 1, 400, NULL); + MSG message; + while (GetMessageW(&message, NULL, 0, 0) > 0) { + TranslateMessage(&message); DispatchMessageW(&message); + } + DestroyMenu(context); + return 0; +} diff --git a/tests/menu_integration.py b/tests/menu_integration.py new file mode 100644 index 0000000..6e68424 --- /dev/null +++ b/tests/menu_integration.py @@ -0,0 +1,100 @@ +"""Exercise a real Wine menu hook against a private D-Bus recording service. + +Requires a graphical session, PyGObject, Wine, and MinGW. No speech is sent to +the running reader. The fixture window closes automatically. +""" +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +from gi.repository import Gio, GLib + +parser = argparse.ArgumentParser() +parser.add_argument("--build-dir", default="build") +parser.add_argument("--session", action="store_true") +args = parser.parse_args() +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)])) + +interface = "org.stormux.Cthulhu1.Service" +object_path = "/org/stormux/Cthulhu1/Service" +xml = '' +for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]): + xml += f'' +xml += '' +events = [] + +def record(connection, sender, path, iface, method, parameters, invocation): + events.append(parameters.unpack()) + invocation.return_value(GLib.Variant("(b)", (True,))) + +connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) +connection.register_object(object_path, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None) +connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "RequestName", + GLib.Variant("(su)", (interface, 0)), None, + Gio.DBusCallFlags.NONE, 1000, None) + +with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: + env = os.environ.copy() + env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all", + WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1") + fixture = Path(temporary) / "menu-fixture.exe" + subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows", + "-o", str(fixture), str(Path(__file__).with_name("menu_fixture.c")), + "-luser32"], check=True) + helper = None + app = None + try: + subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45) + helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env) + # Allow the helper to register its bridge before opening the fixture. + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + time.sleep(0.01) + assert helper.poll() is None, "helper exited before fixture startup" + app = subprocess.Popen(["wine", str(fixture)], env=env) + deadline = time.monotonic() + 15 + while app.poll() is None and time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + time.sleep(0.01) + assert app.poll() == 0, "fixture failed or timed out" + for _ in range(20): + while GLib.MainContext.default().iteration(False): + pass + time.sleep(0.01) + menus = [event for event in events if ":menu:" in event[0]] + for event in menus: + print(event[2:7]) + assert any(e[2] == "File" and e[3] == "submenu" for e in menus), "File menu missing" + assert any(e[2] == "Open" and e[4] == "Ctrl+O" for e in menus), "Open or shortcut missing" + assert any(e[2] == "Word wrap" and e[6] == "checked" for e in menus), "checked item missing" + assert any(e[2] == "Edit" and e[3] == "submenu" for e in menus), "Edit menu missing" + assert any(e[2] == "Cut" and e[4] == "Ctrl+X" for e in menus), "Edit command missing" + assert any(e[2] == "Context action" for e in menus), "context menu missing" + assert any(e[2] == "Favorites" and e[3] == "submenu" for e in menus), "nested submenu missing" + assert any(e[2] == "Nested action" for e in menus), "nested command missing" + saved = [e for e in menus if e[2] == "Pinned & offline"] + assert len(saved) == 2, f"duplicate suppression/menu reset failed: {len(saved)}" + assert all(e[4] == "Ctrl+P" and e[6] == "checked, unavailable" for e in saved) + assert any(e[2] == "Return to control" and e[3] == "button" for e in events), "control focus missing" + subprocess.run([str(build / "cthulhu-wine-access.exe"), + "--no-dialog-reader", "--no-controller"], + env=env, check=True, timeout=5) + print("PASS: native and nested menus, context menus, shortcuts, states, " + "duplicate suppression, reopening, control focus, and disable options") + finally: + # Only this test's newly created Wine prefix is affected. + subprocess.run(["wineserver", "-k"], env=env, timeout=10, check=False) + for child in (app, helper): + if child is not None: + child.wait(timeout=10)