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
@@ -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