First pass at handling menus in wine.
This commit is contained in:
1 parent
cc6523e6fd
commit
0992ffee0d
9 files changed
+520
-18
No files matched your search
@@ -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}")
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user