From 6e92ad13d199ffcfdc7e44d0a1d20b112ae072a0 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 13 Oct 2026 17:42:56 -0400 Subject: [PATCH] Updates for when prism doesn't behave. --- CMakeLists.txt | 6 +- README.md | 21 +++- .../ArchLinux/wine-access-git/PKGBUILD | 2 +- edit_hook.c | 2 + main.cpp | 31 +++++ menu_bridge.h | 7 ++ menu_hook.c | 2 + notification_hook.c | 108 ++++++++++++++++ notification_hook.h | 5 + tests/README.md | 23 ++++ tests/notification_fixture.c | 115 ++++++++++++++++++ tests/notification_integration.py | 98 +++++++++++++++ 12 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 notification_hook.c create mode 100644 notification_hook.h create mode 100644 tests/notification_fixture.c create mode 100644 tests/notification_integration.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 946a198..1560737 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,8 @@ add_custom_command(OUTPUT "${menu_hook}" -static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/scintilla_hook.c" -luser32 -lcomctl32 -lgdi32 - DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h control_class.h scintilla_hook.c scintilla_messages.h + "${CMAKE_CURRENT_SOURCE_DIR}/notification_hook.c" -loleaut32 + DEPENDS menu_hook.c menu_bridge.h notification_hook.c notification_hook.h edit_hook.c edit_hook.h control_class.h scintilla_hook.c scintilla_messages.h VERBATIM) add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}") @@ -30,10 +31,11 @@ add_custom_command(OUTPUT "${menu_hook32}" "${menu_host32}" -static-libgcc -Wl,--kill-at -o "${menu_hook32}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32 + "${CMAKE_CURRENT_SOURCE_DIR}/notification_hook.c" -loleaut32 COMMAND "${MINGW32_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -mwindows -static-libgcc -o "${menu_host32}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_host32.c" -luser32 - DEPENDS menu_hook.c menu_bridge.h menu_host32.c edit_hook.c edit_hook.h control_class.h + DEPENDS menu_hook.c menu_bridge.h notification_hook.c notification_hook.h menu_host32.c edit_hook.c edit_hook.h control_class.h VERBATIM) add_custom_target(wine-access-menu32 ALL DEPENDS "${menu_hook32}" "${menu_host32}") diff --git a/README.md b/README.md index 5b297d0..96a7fc3 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ and readers that implement its Wine D-Bus protocol. An Orca endpoint is available for future Orca-side integration; stock Orca does not implement the required Wine methods. -It provides two independent capabilities: +It provides these capabilities: - an MSAA/WinEvent reader for standard Wine controls; - NVDA Controller 1, 2, and 3 plus Tolk compatibility for applications using - `nvdaControllerClient32.dll`, `nvdaControllerClient64.dll`, or `Tolk.dll`. + `nvdaControllerClient32.dll`, `nvdaControllerClient64.dll`, or `Tolk.dll`; +- a UIA notification bridge for applications whose announcements Wine discards, + including Prism's UIA speech backend. The compatibility window is hidden and non-activating. It is expected to be invisible; the important endpoint is the local Wine RPC server registered by @@ -17,6 +19,21 @@ the helper. The helper forwards speech, braille, cancellation, and standard control events to the selected reader on the session D-Bus. The default is `org.stormux.Cthulhu1.Service`. +Wine currently returns success from `UiaRaiseNotificationEvent` without +delivering its text. The native application hooks redirect direct imports of +that function to the reader. This works with both 32-bit and 64-bit applications; +it requires no application files or per-game DLL overrides. Notifications from +background processes are ignored. Important notifications interrupt speech, +and an empty important notification cancels speech. Each notification is +bounded to 4095 UTF-16 code units without splitting surrogate pairs. + +The bridge covers direct named imports, rather than calls obtained dynamically +with `GetProcAddress`. It leaves implementations that reject a missing provider +untouched. The hook DLL remains mapped until the application exits so redirected +imports remain valid across reader restarts. Close and reopen applications after +updating Wine Access to load the new hooks. `--no-dialog-reader` disables these +hooks along with standard control reading. + The executable is named `cthulhu-wine-access.exe` for compatibility with the Cthulhu-Go supervisor. diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index da03277..8f6fb2c 100644 --- a/distro-packages/ArchLinux/wine-access-git/PKGBUILD +++ b/distro-packages/ArchLinux/wine-access-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=wine-access-git -pkgver=r24.g0f59809 +pkgver=r29.g0d9cc26 pkgrel=1 pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu' arch=('x86_64') diff --git a/edit_hook.c b/edit_hook.c index 36fb1a6..e8aa24f 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -2,6 +2,7 @@ #include "edit_hook.h" #include "menu_bridge.h" #include "control_class.h" +#include "notification_hook.h" #include #include @@ -257,6 +258,7 @@ BOOL wine_access_watch_edit(HWND window) { __declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) { if (code >= 0 && wp == PM_REMOVE) { + wine_access_watch_notifications(); HWND focus = GetFocus(); if (wine_access_watch_edit(focus)) SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); diff --git a/main.cpp b/main.cpp index 51981f7..b98ecd7 100644 --- a/main.cpp +++ b/main.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -34,6 +35,7 @@ ReviewIdentityStore reviewIdentities; ReviewService reviewService; std::deque menuEvents; std::deque textEvents; +std::deque notificationEvents; bool editReaderActive = false; std::string lastMenuEvent; ULONGLONG lastMenuOwner = 0; @@ -668,6 +670,18 @@ void present_text_event(const WineAccessTextEvent &event) { 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_NOTIFICATION_TAG && + data->cbData == sizeof(WineAccessNotificationEvent) && + data->lpData != nullptr && notificationEvents.size() < 32) { + auto event = *static_cast(data->lpData); + event.text[std::size(event.text) - 1] = L'\0'; + notificationEvents.push_back(event); + if (!PostMessageW(window, WINE_ACCESS_MENU_MESSAGE + 2, 0, 0)) { + notificationEvents.pop_back(); + return FALSE; + } + return TRUE; + } if (data->dwData == WINE_ACCESS_TEXT_TAG && data->cbData == sizeof(WineAccessTextEvent) && data->lpData != nullptr && textEvents.size() < 32) { auto event = *static_cast(data->lpData); @@ -710,6 +724,23 @@ LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM l } return 0; } + if (message == WINE_ACCESS_MENU_MESSAGE + 2) { + if (!notificationEvents.empty()) { + const auto event = notificationEvents.front(); + notificationEvents.pop_front(); + DWORD foregroundProcess = 0; + GetWindowThreadProcessId(GetForegroundWindow(), &foregroundProcess); + if (event.process == foregroundProcess) { + const bool interrupt = event.processing == NotificationProcessing_ImportantAll || + event.processing == NotificationProcessing_ImportantMostRecent; + if (event.text[0]) + call_boolean("SpeakText", g_variant_new("(sb)", to_utf8(event.text).c_str(), interrupt)); + else if (interrupt) + call_boolean("CancelSpeech", g_variant_new("()")); + } + } + return 0; + } return DefWindowProcW(window, message, wp, lp); } diff --git a/menu_bridge.h b/menu_bridge.h index a34c230..d4208c2 100644 --- a/menu_bridge.h +++ b/menu_bridge.h @@ -20,6 +20,13 @@ #define WINE_ACCESS_TEXT_INSERTED 8 #define WINE_ACCESS_TEXT_SCINTILLA 1 #define WINE_ACCESS_TEXT_READONLY 2 +#define WINE_ACCESS_NOTIFICATION_TAG 0x57414e31 + +typedef struct WineAccessNotificationEvent { + DWORD process; + UINT processing; + WCHAR text[4096]; +} WineAccessNotificationEvent; typedef struct WineAccessTextEvent { ULONGLONG owner; diff --git a/menu_hook.c b/menu_hook.c index 7967e6c..530a426 100644 --- a/menu_hook.c +++ b/menu_hook.c @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "menu_bridge.h" #include "edit_hook.h" +#include "notification_hook.h" static void menu_text(WCHAR *output, UINT capacity, const WCHAR *input) { UINT used = 0; @@ -19,6 +20,7 @@ static void menu_text(WCHAR *output, UINT capacity, const WCHAR *input) { __declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp, LPARAM lp) { if (code >= 0) { + wine_access_watch_notifications(); const CWPSTRUCT *message = (const CWPSTRUCT *)lp; if (message->message == WM_SETFOCUS) wine_access_watch_edit(message->hwnd); if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP) { diff --git a/notification_hook.c b/notification_hook.c new file mode 100644 index 0000000..844a6a9 --- /dev/null +++ b/notification_hook.c @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#define COBJMACROS +#include "notification_hook.h" +#include "menu_bridge.h" +#include +#include +#include +#include +#include + +// Wine currently drops UiaRaiseNotificationEvent. Replace direct imports in +// applications already reached by our native hook; leave other UIA APIs alone. +// Keep the DLL mapped for the application's lifetime once an import points here. +static HRESULT WINAPI raise_notification(IRawElementProviderSimple *provider, + enum NotificationKind kind, enum NotificationProcessing processing, + BSTR displayString, BSTR activityId) { + (void)kind; (void)activityId; + if (!provider) return E_INVALIDARG; + HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL); + if (!bridge) return S_OK; + WineAccessNotificationEvent event = {0}; + event.process = GetCurrentProcessId(); + event.processing = processing; + UINT length = displayString ? SysStringLen(displayString) : 0; + if (length >= ARRAYSIZE(event.text)) length = ARRAYSIZE(event.text) - 1; + if (length && displayString[length - 1] >= 0xd800 && + displayString[length - 1] <= 0xdbff) --length; + if (length) memcpy(event.text, displayString, length * sizeof(WCHAR)); + COPYDATASTRUCT data = {WINE_ACCESS_NOTIFICATION_TAG, sizeof(event), &event}; + DWORD_PTR result = 0; + // The receiver only copies and queues. No reader calls on an app's thread. + SendMessageTimeoutW(bridge, WM_COPYDATA, 0, (LPARAM)&data, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result); + return S_OK; +} + +static void watch_module(BYTE *base) { + IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base; + if (dos->e_magic != IMAGE_DOS_SIGNATURE) return; + IMAGE_NT_HEADERS *header = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew); + if (header->Signature != IMAGE_NT_SIGNATURE) return; + IMAGE_DATA_DIRECTORY directory = + header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if (!directory.VirtualAddress || !directory.Size) return; + IMAGE_IMPORT_DESCRIPTOR *imports = + (IMAGE_IMPORT_DESCRIPTOR *)(base + directory.VirtualAddress); + for (; imports->Name; ++imports) { + if (lstrcmpiA((char *)(base + imports->Name), "uiautomationcore.dll") || + !imports->OriginalFirstThunk) continue; + IMAGE_THUNK_DATA *names = (IMAGE_THUNK_DATA *)(base + imports->OriginalFirstThunk); + IMAGE_THUNK_DATA *addresses = (IMAGE_THUNK_DATA *)(base + imports->FirstThunk); + for (; names->u1.AddressOfData; ++names, ++addresses) { + if (IMAGE_SNAP_BY_ORDINAL(names->u1.Ordinal)) continue; + IMAGE_IMPORT_BY_NAME *name = (IMAGE_IMPORT_BY_NAME *)(base + names->u1.AddressOfData); + if (strcmp((char *)name->Name, "UiaRaiseNotificationEvent") || + addresses->u1.Function == (ULONG_PTR)raise_notification) continue; + // The Wine stub even accepts a missing required provider. Leave an + // implementation which rejects it (including native UIA) untouched. + typedef HRESULT (WINAPI *RaiseNotification)(IRawElementProviderSimple *, + enum NotificationKind, enum NotificationProcessing, BSTR, BSTR); + RaiseNotification original = (RaiseNotification)(ULONG_PTR)addresses->u1.Function; + if (original(NULL, NotificationKind_ActionCompleted, + NotificationProcessing_All, NULL, NULL) != S_OK) continue; + HMODULE pinnedModule = NULL; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_PIN, (LPCWSTR)(ULONG_PTR)raise_notification, + &pinnedModule)) continue; + DWORD protection; + if (!VirtualProtect(&addresses->u1.Function, sizeof(addresses->u1.Function), + PAGE_READWRITE, &protection)) continue; + InterlockedExchangePointer((PVOID volatile *)&addresses->u1.Function, + (PVOID)(ULONG_PTR)raise_notification); + DWORD ignored; + VirtualProtect(&addresses->u1.Function, sizeof(addresses->u1.Function), + protection, &ignored); + } + } +} + +void wine_access_watch_notifications(void) { + static DWORD lastScan; + static LONG scanning; + DWORD now = GetTickCount(); + if (InterlockedCompareExchange(&scanning, 1, 0)) return; + if (lastScan && now - lastScan < 1000) { + InterlockedExchange(&scanning, 0); + return; + } + lastScan = now; + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId()); + if (snapshot != INVALID_HANDLE_VALUE) { + MODULEENTRY32W module = {0}; + module.dwSize = sizeof(module); + if (Module32FirstW(snapshot, &module)) { + do { + // Hold a reference while walking imports: another thread may unload it. + HMODULE reference = NULL; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + (LPCWSTR)module.modBaseAddr, &reference)) { + watch_module((BYTE *)reference); + FreeLibrary(reference); + } + } while (Module32NextW(snapshot, &module)); + } + CloseHandle(snapshot); + } + InterlockedExchange(&scanning, 0); +} diff --git a/notification_hook.h b/notification_hook.h new file mode 100644 index 0000000..42d7250 --- /dev/null +++ b/notification_hook.h @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include + +void wine_access_watch_notifications(void); diff --git a/tests/README.md b/tests/README.md index 8a7b2a7..1c8da2b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,28 @@ # Wine control integration +UIA notification routing uses the actual native application hooks and a private +recording reader, without sending speech to the live reader: + +```sh +xvfb-run -a python3 tests/notification_integration.py --build-dir build +xvfb-run -a python3 tests/notification_integration.py --build-dir build --32-bit --reader orca +``` + +To exercise a real 64-bit Prism DLL through its UIA backend, provide the matching +Prism C headers (`prism.h` and `prism_version.h`) without replacing the DLL: + +```sh +xvfb-run -a python3 tests/notification_integration.py --build-dir build \ + --prism-dll /path/to/prism.dll --prism-include /path/to/prism/headers +``` + +These checks cover Unicode text, interruption, empty-message cancellation, +bounded payloads, and ignoring a background application's announcements. They +do not establish audible speech. After installing, restart the reader and game; +in Arcadelux, verify the introduction and repeated Up/Down menu announcements, +including switching away and back. Also check existing NVDA/Tolk speech and +ordinary Wine menus and Edit controls before submitting the change. + ## Reader endpoint selection After building, run these checks sequentially on private displays: diff --git a/tests/notification_fixture.c b/tests/notification_fixture.c new file mode 100644 index 0000000..50ce546 --- /dev/null +++ b/tests/notification_fixture.c @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#define COBJMACROS +#include +#include +#include +#include +#include +#include +#ifdef WINE_ACCESS_PRISM_FIXTURE +#include +#define PRISM_STATIC +#include +#define LOAD(name) static __typeof__(&name) p_##name +LOAD(prism_config_init); LOAD(prism_init); LOAD(prism_registry_acquire_best); +LOAD(prism_backend_name); LOAD(prism_backend_speak); LOAD(prism_shutdown); +LOAD(prism_backend_stop); +static PrismContext *context; +static PrismBackend *backend; +#endif + +static HWND window; +static int step = -2; + +static void notify(const WCHAR *text, enum NotificationProcessing processing) { +#ifdef WINE_ACCESS_PRISM_FIXTURE + char utf8[20000]; + WideCharToMultiByte(CP_UTF8, 0, text, -1, utf8, sizeof(utf8), NULL, NULL); + BOOL interrupt = processing == NotificationProcessing_ImportantMostRecent || + processing == NotificationProcessing_ImportantAll; + // Prism rejects empty speak text; its stop command emits an empty important + // UIA notification instead. + PrismError result = utf8[0] ? p_prism_backend_speak(backend, utf8, interrupt) : + interrupt ? p_prism_backend_stop(backend) : PRISM_OK; + if (result != PRISM_OK) ExitProcess(4); +#else + BSTR displayString = SysAllocString(text); + BSTR activity = SysAllocString(L"Fixture"); + IRawElementProviderSimple *provider = NULL; + if (FAILED(UiaHostProviderFromHwnd(window, &provider)) || !provider) ExitProcess(2); + HRESULT result = UiaRaiseNotificationEvent(provider, NotificationKind_ActionCompleted, + processing, displayString, activity); + IRawElementProviderSimple_Release(provider); + SysFreeString(displayString); + SysFreeString(activity); + if (FAILED(result)) ExitProcess(3); +#endif +} + +static LRESULT CALLBACK window_proc(HWND hwnd, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_TIMER) { + // Real UIA calls through normal imports, reached by the global native hook. + switch (++step) { + case -1: case 0: break; + case 1: notify(L"Menu caf\u00e9 \U0001f600", NotificationProcessing_All); break; + case 2: notify(L"Second item", NotificationProcessing_ImportantMostRecent); break; + case 3: notify(L"", NotificationProcessing_ImportantMostRecent); break; + case 4: notify(L"", NotificationProcessing_All); break; + case 5: { + WCHAR longText[4100]; + for (UINT i = 0; i < ARRAYSIZE(longText) - 1; ++i) longText[i] = L'x'; + longText[4094] = 0xd83d; longText[4095] = 0xde00; + longText[ARRAYSIZE(longText) - 1] = 0; + notify(longText, NotificationProcessing_All); + break; + } + case 6: ShowWindow(hwnd, SW_HIDE); break; + case 7: notify(L"Background must stay silent", NotificationProcessing_ImportantAll); break; + default: DestroyWindow(hwnd); break; + } + return 0; + } + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(hwnd, message, wp, lp); +} + +int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { + (void)previous; (void)args; (void)show; + CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + WNDCLASSW cls = {0}; + cls.hInstance = instance; cls.lpfnWndProc = window_proc; + cls.lpszClassName = L"WineAccessNotificationFixture"; + RegisterClassW(&cls); + window = CreateWindowW(cls.lpszClassName, L"Notification fixture", WS_OVERLAPPEDWINDOW, + 10, 10, 300, 200, NULL, NULL, instance, NULL); + if (!window) return 1; + ShowWindow(window, SW_SHOW); SetForegroundWindow(window); +#ifdef WINE_ACCESS_PRISM_FIXTURE + int argumentCount = 0; + WCHAR **arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount); + HMODULE module = argumentCount == 2 ? LoadLibraryW(arguments[1]) : NULL; + LocalFree(arguments); + if (!module) return 5; +#undef LOAD +#define LOAD(name) do { FARPROC address = GetProcAddress(module, #name); \ + memcpy(&p_##name, &address, sizeof(p_##name)); if (!p_##name) return 6; } while (0) + LOAD(prism_config_init); LOAD(prism_init); LOAD(prism_registry_acquire_best); + LOAD(prism_backend_name); LOAD(prism_backend_speak); LOAD(prism_shutdown); + LOAD(prism_backend_stop); + PrismConfig config = p_prism_config_init(); + context = p_prism_init(&config); + if (!context) return 7; + backend = p_prism_registry_acquire_best(context); + if (!backend || strcmp(p_prism_backend_name(backend), "UIA")) return 8; +#endif + SetTimer(window, 1, 500, NULL); + MSG message; + while (GetMessageW(&message, NULL, 0, 0) > 0) { + TranslateMessage(&message); DispatchMessageW(&message); + } +#ifdef WINE_ACCESS_PRISM_FIXTURE + p_prism_shutdown(context); +#endif + CoUninitialize(); + return 0; +} diff --git a/tests/notification_integration.py b/tests/notification_integration.py new file mode 100644 index 0000000..ab1d03a --- /dev/null +++ b/tests/notification_integration.py @@ -0,0 +1,98 @@ +"""Route real UIA notifications through native hooks to a private reader.""" +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("--reader", choices=("cthulhu", "orca"), default="cthulhu") +parser.add_argument("--32-bit", dest="is32Bit", action="store_true") +parser.add_argument("--session", action="store_true") +parser.add_argument("--prism-dll", type=Path) +parser.add_argument("--prism-include", type=Path) +args = parser.parse_args() +if bool(args.prism_dll) != bool(args.prism_include) or (args.prism_dll and args.is32Bit): + parser.error("Prism checks require both --prism-dll and --prism-include, and a 64-bit DLL") +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), "--reader", args.reader, + *(["--prism-dll", str(args.prism_dll.resolve()), + "--prism-include", str(args.prism_include.resolve())] + if args.prism_dll else []), + *(["--32-bit"] if args.is32Bit else [])])) + +interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" +objectPath = "/" + interface.replace(".", "/") +xml = '' +for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", ""), + ("PresentAccessibleEvent", "sssssssiis")): + xml += '' + xml += ''.join('' for kind in inputs) + xml += '' +xml += '' +events = [] + + +def record(connection, sender, path, iface, method, parameters, invocation): + if method != "PresentAccessibleEvent": + events.append((method, parameters.unpack())) + invocation.return_value(GLib.Variant("(b)", (True,))) + + +connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) +connection.register_object(objectPath, 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-notifications-") as temporary: + tempPath = Path(temporary) + env = os.environ.copy() + env.update(WINEPREFIX=str(tempPath / "prefix"), WINEDEBUG="-all", + WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1") + fixture = tempPath / "notification-fixture.exe" + compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc" + subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", "-o", str(fixture), + *(["-DWINE_ACCESS_PRISM_FIXTURE", "-I" + str(args.prism_include.resolve())] + if args.prism_dll else []), + str(Path(__file__).with_name("notification_fixture.c")), + "-luser32", "-lole32", "-loleaut32", "-luiautomationcore", "-lshell32"], check=True) + helper = app = None + try: + subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45) + helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller", + "--reader", args.reader], env=env) + time.sleep(.5) + app = subprocess.Popen(["wine", str(fixture), + *(["Z:" + str(args.prism_dll.resolve()).replace("/", "\\")] + if args.prism_dll else [])], env=env) + deadline = time.monotonic() + 20 + while app.poll() is None and time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + time.sleep(.005) + while GLib.MainContext.default().iteration(False): + pass + assert app.poll() == 0, f"notification fixture failed: {app.poll()}" + assert helper.poll() is None, "helper exited during notifications" + assert events == [ + ("SpeakText", ("Menu caf\u00e9 \U0001f600", False)), + ("SpeakText", ("Second item", True)), + ("CancelSpeech", ()), + ("SpeakText", ("x" * 4094, False)), + ], events + print(f"PASS: {32 if args.is32Bit else 64}-bit UIA text, interruption, cancellation, " + f"Unicode, bounded text, and background silence through {args.reader}") + finally: + for child in (app, helper): + if child is not None and child.poll() is None: + child.terminate() + child.wait(timeout=5) + subprocess.run(["wineserver", "-k"], env=env, timeout=5)