First pass at handling menus in wine.

This commit is contained in:
Storm Dragon committed 2026-10-09 15:45:18 -04:00
1 parent cc6523e6fd
commit 0992ffee0d
9 files changed
+520 -18

No files matched your search

+14
View File
@@ -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}")
+31 -1
View File
@@ -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
@@ -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.
+146 -16
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "nvdaController.h"
#include "menu_bridge.h"
#include <commctrl.h>
#include <gio/gio.h>
@@ -9,8 +10,10 @@
#include <windows.h>
#include <cstdint>
#include <bit>
#include <cstdlib>
#include <cwchar>
#include <cstdio>
#include <deque>
#include <iterator>
#include <string>
@@ -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<WineAccessMenuEvent> 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<HWND>(static_cast<UINT_PTR>(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<const COPYDATASTRUCT *>(lp);
if (data->dwData != WINE_ACCESS_MENU_TAG || data->cbData != sizeof(WineAccessMenuEvent) ||
data->lpData == nullptr || menuEvents.size() >= 32)
return FALSE;
auto event = *static_cast<const WineAccessMenuEvent *>(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<size_t>(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<HOOKPROC>(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<unsigned long>(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();
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
#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;
+68
View File
@@ -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);
}
+33
View File
@@ -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.
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h>
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;
}
+100
View File
@@ -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 = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]):
xml += f'<arg name="arg{index}" type="{kind}" direction="in"/>'
xml += '<arg type="b" direction="out"/></method></interface></node>'
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)