From b16c6968ae7057993af9515a4b9e572f4de9d771 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Wed, 14 Oct 2026 20:55:28 -0400 Subject: [PATCH] Code optimization and bug fixes. --- README.md | 3 +- .../ArchLinux/wine-access-git/PKGBUILD | 5 ++- edit_hook.c | 38 ++++++++++++++----- main.cpp | 5 ++- tests/README.md | 5 +++ tests/controller_integration.py | 36 +++++++++++++++--- tests/edit_fixture.c | 34 ++++++++++++++++- tests/event_stall_integration.py | 7 +++- tests/menu_integration.py | 9 ++++- tests/reader_stall_integration.py | 7 +++- tests/review_integration.py | 3 +- 11 files changed, 124 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8687462..26f4875 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,8 @@ selected object and interface and checks the required method signatures for the enabled capabilities. A missing extension or incompatible interface causes startup to fail with a diagnostic. Controller support requires `SpeakText`, `CancelSpeech`, `BrailleMessage` and `SpeakSsml`; control reading -requires `PresentAccessibleEvent`. Review methods remain optional. The +requires `PresentAccessibleEvent`, plus `SpeakText` and `CancelSpeech` for UIA +notifications even with `--no-controller`. Review methods remain optional. The interface must be published before launching the helper. This check runs at startup; later request failures still follow the ordinary error handling. diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index 40d52fb..386a7a2 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=r37.g417ee40 +pkgver=r40.g4c47cc5 pkgrel=1 pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for screen readers' arch=('x86_64') @@ -32,7 +32,8 @@ build() { -DCMAKE_CXX_FLAGS="$CXXFLAGS" \ -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \ -DCMAKE_INSTALL_PREFIX=/usr \ - -DCMAKE_INSTALL_LIBEXECDIR=libexec + -DCMAKE_INSTALL_LIBEXECDIR=libexec \ + -DWINE_ACCESS_INSTALL_DIR:PATH=libexec/wine-access cmake --build "$srcdir/build" } diff --git a/edit_hook.c b/edit_hook.c index 6a935ae..e7c1cd6 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -105,7 +105,12 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l BOOL selected = message == EM_SETSEL; // A dispatched arrow belongs to ordinary caret navigation. Applications // such as MUSHclient consume history arrows before DispatchMessage instead. - if (navigation || focus) state->history_key = state->history_replaced = FALSE; + // GetMessage can dispatch cross-thread sends before retrieving another + // queued message. Those replacements are not part of the UI thread's + // pretranslated history key, even when a history boundary left it pending. + BOOL remote_replacement = message == WM_SETTEXT && InSendMessageEx(NULL) != ISMEX_NOSEND; + if (navigation || focus || message == WM_KILLFOCUS || remote_replacement) + state->history_key = state->history_replaced = FALSE; BOOL history_replacement = message == WM_SETTEXT && state->history_key; if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR) return DefSubclassProc(window, message, wp, lp); @@ -243,7 +248,8 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l return result; } -BOOL wine_access_watch_edit(HWND window) { +static BOOL watch_edit(HWND window, EditState **edit_state) { + if (edit_state) *edit_state = NULL; WCHAR class_name[64]; if (!window || !GetClassNameW(window, class_name, 64)) return FALSE; #ifdef _WIN64 @@ -253,8 +259,12 @@ BOOL wine_access_watch_edit(HWND window) { if (!wine_access_class_matches(class_name, L"Edit")) return FALSE; // Wine's comctl32 v5 exports GetWindowSubclass only by ordinal. Our own // property also keeps attachment independent of the application's manifest. - if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE; - EditState *state = calloc(1, sizeof(*state)); + EditState *state = (EditState *)GetPropW(window, EDIT_STATE_PROPERTY); + if (state) { + if (edit_state) *edit_state = state; + return FALSE; + } + state = calloc(1, sizeof(*state)); if (!state) return FALSE; if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) { free(state); return FALSE; @@ -268,20 +278,28 @@ BOOL wine_access_watch_edit(HWND window) { HMODULE module; GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, (LPCWSTR)(UINT_PTR)edit_proc, &module); + if (edit_state) *edit_state = state; return TRUE; } +BOOL wine_access_watch_edit(HWND window) { + return watch_edit(window, NULL); +} + __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)) + EditState *state; + if (watch_edit(focus, &state)) { SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); - EditState *state = (EditState *)GetPropW(focus, EDIT_STATE_PROPERTY); - WCHAR class_name[64]; - // Scintilla uses the same attachment property with a different state. - if (state && GetClassNameW(focus, class_name, 64) && - wine_access_class_matches(class_name, L"Edit") && !state->busy) { + // The synchronous callback can destroy the control. Revalidate only + // on initial attachment, not for every message of an existing editor. + watch_edit(focus, &state); + } + // watch_edit returns typed state only for Edit, never for Scintilla's + // different structure stored under the same attachment property. + if (state && !state->busy) { const MSG *message = (const MSG *)lp; // Restrict replacement feedback to this retrieved input message. // Timers, unrelated programmatic changes and background edits stay silent. diff --git a/main.cpp b/main.cpp index 9dc7345..cd6d6d6 100644 --- a/main.cpp +++ b/main.cpp @@ -176,8 +176,9 @@ bool reader_supports_protocol(bool controllerEnabled, bool dialogReaderEnabled) bool required; }; const RequiredMethod methods[] = { - {"SpeakText", "sb", "b", controllerEnabled}, - {"CancelSpeech", "", "b", controllerEnabled}, + // UIA notifications use speech directly even without the RPC controller. + {"SpeakText", "sb", "b", controllerEnabled || dialogReaderEnabled}, + {"CancelSpeech", "", "b", controllerEnabled || dialogReaderEnabled}, {"BrailleMessage", "s", "b", controllerEnabled}, {"SpeakSsml", "ss", "i", controllerEnabled}, {"PresentAccessibleEvent", "sssssssiis", "b", dialogReaderEnabled}}; diff --git a/tests/README.md b/tests/README.md index 116ed84..061a6a2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -104,6 +104,8 @@ invalid reader values, and startup refusal when only the unselected service exists. Both service names are owned while the selected reader handles requests. It also rejects invalid endpoint overrides, missing objects/interfaces, missing required methods and incorrect input/output signatures. +UIA speech and cancellation methods are checked even with the RPC controller +disabled; missing methods and incompatible input/output signatures must fail startup. The menu/editor and review tests also accept `--reader cthulhu`; omit the option to check existing default behavior. Review checks reject callers even when they own the unselected service, then replace the selected service owner and verify @@ -308,6 +310,9 @@ input rejected at the text limit. Command-history checks consume queued Up/Down before dispatch, then replace the text and move the caret as MUSHclient does. They require ordered, single announcements for equal-length and rapid recalls and empty input, with no password, background or unrelated programmatic output. +An unchanged history boundary is followed by a worker-thread text replacement +before another queued message is removed. That replacement must stay silent, +and subsequent navigation must read from the updated text snapshot. Both 32-bit and 64-bit fixtures use the same assertions. Lifecycle variants attach after the control has focus or restart the helper without closing it. Every text action must have an established diff --git a/tests/controller_integration.py b/tests/controller_integration.py index a4f46b1..8c71c60 100644 --- a/tests/controller_integration.py +++ b/tests/controller_integration.py @@ -29,12 +29,18 @@ objectPath = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_ endpointArguments = ["--reader-path", objectPath, "--reader-interface", interface] if args.extension_endpoint else [] methods = {"SpeakText": ("sb", "b"), "CancelSpeech": ("", "b"), "BrailleMessage": ("s", "b"), "SpeakSsml": ("ss", "i")} -xml = '' -for method, (inputs, output) in methods.items(): - xml += '' - xml += ''.join('' for kind in inputs) - xml += '' -xml += '' + + +def interface_xml(methods): + xml = '' + for method, (inputs, output) in methods.items(): + xml += '' + xml += ''.join('' for kind in inputs) + xml += '' + return xml + '' + + +xml = interface_xml(methods) events = [] @@ -125,6 +131,24 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary: broken = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(brokenXml).interfaces[0], record, None, None) assert "reader method" in reject(helperCommand) connection.unregister_object(broken) + # UIA still uses direct speech when the NVDA RPC controller is disabled. + dialogMethods = {"SpeakText": ("sb", "b"), "CancelSpeech": ("", "b"), + "PresentAccessibleEvent": ("sssssssiis", "b")} + dialogCommand = ["--no-controller" if part == "--no-dialog-reader" else part + for part in helperCommand] + for method in ("SpeakText", "CancelSpeech"): + for signature in (None, ("s", "b"), (dialogMethods[method][0], "i")): + brokenMethods = dialogMethods.copy() + if signature is None: + del brokenMethods[method] + else: + brokenMethods[method] = signature + broken = connection.register_object(objectPath, + Gio.DBusNodeInfo.new_for_xml(interface_xml(brokenMethods)).interfaces[0], + record, None, None) + assert "reader method " + method in reject(dialogCommand) + connection.unregister_object(broken) + print("PASS: UIA speech methods required without the controller") registration = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None) # Controller-only interfaces must not enable control reading. assert "PresentAccessibleEvent" in reject([part for part in helperCommand if part != "--no-dialog-reader"]) diff --git a/tests/edit_fixture.c b/tests/edit_fixture.c index 95efa9d..9feda70 100644 --- a/tests/edit_fixture.c +++ b/tests/edit_fixture.c @@ -22,6 +22,29 @@ static HWND editor, password, history; static int step; static int history_position; +static DWORD WINAPI update_history_from_worker(void *window) { + SendMessageW((HWND)window, WM_SETTEXT, 0, (LPARAM)L"unrelated worker update"); + return 0; +} + +static void unrelated_history_update(void) { + HANDLE thread = CreateThread(NULL, 0, update_history_from_worker, history, 0, NULL); + if (!thread) ExitProcess(2); + // Dispatch sent messages without removing a queued message, as GetMessage + // does while waiting for input. Do not rely on worker/timer scheduling. + for (;;) { + DWORD result = MsgWaitForMultipleObjects(1, &thread, FALSE, 2000, QS_SENDMESSAGE); + if (result == WAIT_OBJECT_0) break; + if (result != WAIT_OBJECT_0 + 1) ExitProcess(2); + MSG message; + PeekMessageW(&message, NULL, 0, 0, PM_NOREMOVE); + } + CloseHandle(thread); + WCHAR text[64]; + GetWindowTextW(history, text, 64); + if (lstrcmpW(text, L"unrelated worker update")) ExitProcess(2); +} + // Model MUSHclient's PreTranslateMessage: consume queued arrows before the // Edit procedure, then use SetWindowText and SetSel to recall a command. static BOOL history_input(const MSG *message) { @@ -29,7 +52,10 @@ static BOOL history_input(const MSG *message) { (message->wParam != VK_UP && message->wParam != VK_DOWN)) return FALSE; if (message->wParam == VK_UP && history_position < 2) ++history_position; else if (message->wParam == VK_DOWN && history_position > 0) --history_position; - else return TRUE; + else { + if (step == 49) unrelated_history_update(); + return TRUE; + } const WCHAR *commands[] = {L"", L"history north", L"history south"}; SetWindowTextW(history, commands[history_position]); int length = GetWindowTextLengthW(history); @@ -239,10 +265,14 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); // no history change break; case 50: + // The silent worker update must still refresh the navigation snapshot. + PostMessageW(history, WM_KEYDOWN, VK_RIGHT, 1); + break; + case 51: SendMessageW(history, EM_SETPASSWORDCHAR, L'*', 0); PostMessageW(history, WM_KEYDOWN, VK_UP, 1); break; - case 51: + case 52: SetFocus(password); SendMessageW(history, EM_SETPASSWORDCHAR, 0, 0); PostMessageW(history, WM_KEYDOWN, VK_UP, 1); // background recall diff --git a/tests/event_stall_integration.py b/tests/event_stall_integration.py index d9d6a28..a3e09a1 100644 --- a/tests/event_stall_integration.py +++ b/tests/event_stall_integration.py @@ -35,7 +35,12 @@ interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux objectPath = "/" + interface.replace(".", "/") xml = '' xml += ''.join('' for kind in ["s"] * 7 + ["i", "i", "s"]) -xml += '' +xml += '' +for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")): + xml += '' + xml += ''.join('' for kind in inputs) + xml += '' +xml += '' events = [] diff --git a/tests/menu_integration.py b/tests/menu_integration.py index a6f757f..6d1e72f 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -56,7 +56,12 @@ if args.extension_endpoint: xml = '' for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]): xml += f'' -xml += '' +xml += '' +for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")): + xml += '' + xml += ''.join('' for kind in inputs) + xml += '' +xml += '' events = [] def record(connection, sender, path, iface, method, parameters, invocation): @@ -317,7 +322,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: history_actions = [e[2] for e in text_events if e[0] == history_focus and e[1] == "text"] assert history_actions == ["history north", "history south", "history north", "blank", - "history north", "history south", "history north", "h", "blank", "blank"], \ + "history north", "history south", "history north", "h", "blank", "blank", "n"], \ f"command history missing, reordered, duplicated or leaked: {history_actions!r}" if args.fixture == "winforms": assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta" diff --git a/tests/reader_stall_integration.py b/tests/reader_stall_integration.py index 8e860da..66543b7 100644 --- a/tests/reader_stall_integration.py +++ b/tests/reader_stall_integration.py @@ -31,7 +31,12 @@ interface = "org.stormux.Cthulhu1.Service" object_path = "/org/stormux/Cthulhu1/Service" xml = '' xml += ''.join('' for kind in ["s"] * 7 + ["i", "i", "s"]) -xml += '' +xml += '' +for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")): + xml += '' + xml += ''.join('' for kind in inputs) + xml += '' +xml += '' events, held = [], [] diff --git a/tests/review_integration.py b/tests/review_integration.py index ff42b6d..59488b7 100644 --- a/tests/review_integration.py +++ b/tests/review_integration.py @@ -32,7 +32,8 @@ capture_iface = 'org.stormux.Cthulhu1.WineReview' capture_path = '/org/stormux/Cthulhu1/WineReview' def interface_xml(review=True): - methods = {'PresentAccessibleEvent': ['s']*7+['i','i','s']} + methods = {'PresentAccessibleEvent': ['s']*7+['i','i','s'], + 'SpeakText': ['s','b'], 'CancelSpeech': []} if review: methods.update(RegisterWineReviewProvider=['o','u'], PresentWineReviewFocus=['s','s','s','t','s','s','b','(iiii)']) return ''+''.join(