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

+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)