From 0f59809e9a0ecb65d0fcdf45d746d42c2d6ce161 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 12 Oct 2026 04:37:12 -0400 Subject: [PATCH] Make d-bus available to orca if someone decides they want to implement it. --- CMakeLists.txt | 2 +- README.md | 71 +++++++++++++++++-- main.cpp | 46 +++++++++--- reader_endpoint.h | 15 ++++ review_service.cpp | 19 +++-- review_service.h | 3 +- tests/README.md | 31 +++++++++ tests/controller_fixture.c | 52 ++++++++++++++ tests/controller_integration.py | 119 ++++++++++++++++++++++++++++++++ tests/menu_integration.py | 11 +-- tests/review_integration.py | 44 ++++++++++-- 11 files changed, 374 insertions(+), 39 deletions(-) create mode 100644 reader_endpoint.h create mode 100644 tests/controller_fixture.c create mode 100644 tests/controller_integration.py diff --git a/CMakeLists.txt b/CMakeLists.txt index da231a8..946a198 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,7 +54,7 @@ add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32) 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 - PkgConfig::GIO PkgConfig::XCB oleacc ole32 oleaut32 rpcrt4 user32 gdi32) + PkgConfig::GIO PkgConfig::XCB oleacc ole32 oleaut32 rpcrt4 user32 gdi32 shell32) target_link_options(cthulhu-wine-access PRIVATE -mwindows) target_compile_options(cthulhu-wine-access PRIVATE $<$:-Wall -Wextra>) diff --git a/README.md b/README.md index 4dc5368..393280c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # Wine Access `wine-access` is the standalone Wine-hosted accessibility helper for Cthulhu -and other readers that implement the Cthulhu D-Bus service. +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: @@ -12,7 +14,8 @@ It provides two independent capabilities: 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 the helper. The helper forwards speech, braille, cancellation, and standard -control events to `org.stormux.Cthulhu1.Service` on the session D-Bus. +control events to the selected reader on the session D-Bus. The default is +`org.stormux.Cthulhu1.Service`. The executable is named `cthulhu-wine-access.exe` for compatibility with the Cthulhu-Go supervisor. @@ -86,8 +89,66 @@ $HOME/.local/libexec/cthulhu/wine/cthulhu-wine-access.exe ``` The helper is started automatically when Cthulhu-Go detects an active Wine -loader for the same user and prefix. It requires the Go service to be running; -it exits if `org.stormux.Cthulhu1.Service` is unavailable. +loader for the same user and prefix. It requires the selected reader service +to be running; it exits if that service is unavailable. + +## Reader selection and Orca integration + +The default invocation and `--reader cthulhu` use the existing Cthulhu endpoint. +An Orca-side integration can launch the helper in the application's Wine prefix: + +```sh +WINEPREFIX=/path/to/prefix /path/to/cthulhu-wine-access.exe --reader orca +``` + +| Reader | Session bus name and interface | Object path | +| --- | --- | --- | +| Cthulhu (default) | `org.stormux.Cthulhu1.Service` | `/org/stormux/Cthulhu1/Service` | +| Orca | `org.gnome.Orca1.Service` | `/org/gnome/Orca1/Service` | + +Selection applies to controller requests, accessibility events, review +registration and review-capture authorization. Only one helper runs per Wine +desktop/prefix, so stop the existing helper before changing readers. There is +no automatic switching between services. Unknown or missing reader values +are rejected. Orca must arrange helper startup and shutdown itself. + +The Orca endpoint follows the versioned service in Orca's `dbus_service.py`. +It uses the same Wine methods and signatures as Cthulhu, on the selected +service interface: + +| Method | Input signature | Output signature | +| --- | --- | --- | +| `SpeakText` | `sb` (text, interrupt) | `b` | +| `CancelSpeech` | empty | `b` | +| `BrailleMessage` | `s` (text) | `b` | +| `SpeakSsml` | `ss` (source, SSML) | `i` (Windows status code) | +| `PresentAccessibleEvent` | `sssssssiis` | `b` | +| `RegisterWineReviewProvider` (optional) | `ou` (provider path, version) | `b` | +| `PresentWineReviewFocus` (optional) | `ssstssb(iiii)` | `b` | + +Boolean replies indicate whether the reader accepted the request. The helper +queues plain controller speech with `interrupt=false`; Tolk requests +interruption separately through `CancelSpeech`. An unsupported `SpeakSsml` +implementation should return `ERROR_CALL_NOT_IMPLEMENTED` (120). +`PresentAccessibleEvent` arguments are source ID, event type, name, role, +value, states, description, position, count and window title, in that order. +The reader handles focus tracking, event filtering and presentation; preserve +repeated editor actions and exclude password values as described below. + +Orca's existing `SpeakMessage` and `DisplayMessage` methods have different +names/signatures. Wine Access does not translate to those methods. An Orca +contributor must implement the Wine methods above for the capabilities they +want to enable. `--no-controller` and `--no-dialog-reader` can disable either +capability while bringing up the integration. + +Review retains the shared `/org/stormux/Cthulhu1/WineReview` object path and +`org.stormux.Cthulhu1.WineReview` interface for both readers. Use the helper's +unique D-Bus sender from `RegisterWineReviewProvider` as the destination for +`CaptureWindow`. Only the current same-user owner of the selected reader +service may capture; owning the other reader name grants no access. See +[the flat-review contract](#independent-wine-flat-review) for focus arguments, +capture replies and limits. Missing review methods leave ordinary control and +controller forwarding available. When a game closes inside a Wine virtual desktop, the desktop can regain X11 focus while Wine has no foreground application. The helper restores native @@ -254,7 +315,7 @@ Protocol version 1 adds `RegisterWineReviewProvider(ou) -> b` and `PresentWineReviewFocus(ssstssb(iiii)) -> b` to the reader service. The helper exports `/org/stormux/Cthulhu1/WineReview`, interface `org.stormux.Cthulhu1.WineReview`, with `CaptureWindow(sst) -> a{sv}`. Only the -current same-user reader service owner can capture. Replies contain exactly +current same-user owner of the selected reader service can capture. Replies contain exactly `version:u`, `instance:s`, `window:s`, `focus:s`, `revision:t`, `status:s`, `bounds:(iiii)`, and `nodes:aa{sv}`. Nodes have `id`, `parent`, `role`, `name` strings; `protected`, `visible`, `text_known`, `caret_known` booleans; diff --git a/main.cpp b/main.cpp index 3d27182..5b649fc 100644 --- a/main.cpp +++ b/main.cpp @@ -9,12 +9,14 @@ #include "native_items.h" #include "review_service.h" #include "foreground_focus.h" +#include "reader_endpoint.h" #include #include #include #include #include +#include #include #include @@ -25,9 +27,7 @@ #include namespace { -constexpr auto BUS_NAME = "org.stormux.Cthulhu1.Service"; -constexpr auto OBJECT_PATH = "/org/stormux/Cthulhu1/Service"; -constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service"; +ReaderEndpoint readerEndpoint = CthulhuEndpoint; constexpr int DBUS_TIMEOUT_MS = 500; GDBusConnection *connection = nullptr; ReviewIdentityStore reviewIdentities; @@ -112,7 +112,8 @@ DWORD call_boolean(const char *method, GVariant *parameters) { return ERROR_SERVICE_NOT_ACTIVE; GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( - connection, BUS_NAME, OBJECT_PATH, INTERFACE_NAME, method, parameters, + connection, readerEndpoint.busName, readerEndpoint.objectPath, + readerEndpoint.interfaceName, method, parameters, G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); if (error != nullptr) @@ -125,11 +126,12 @@ DWORD call_boolean(const char *method, GVariant *parameters) { return result == TRUE ? ERROR_SUCCESS : ERROR_GEN_FAILURE; } -bool cthulhu_is_available() { +bool reader_is_available() { GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", - "org.freedesktop.DBus", "NameHasOwner", g_variant_new("(s)", BUS_NAME), + "org.freedesktop.DBus", "NameHasOwner", + g_variant_new("(s)", readerEndpoint.busName), G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); if (error != nullptr) @@ -168,7 +170,7 @@ std::string accessible_property( } // Standard-control queries follow the LGPL wine-a11y reader published by -// Mudb0y, while presentation remains on Cthulhu's D-Bus interface. +// Mudb0y, while presentation uses the selected reader's D-Bus interface. void strip_mnemonics(wchar_t *text) { wchar_t *output = text; for (wchar_t *input = text; *input != L'\0'; ++input) { @@ -895,7 +897,7 @@ extern "C" void *__RPC_USER MIDL_user_allocate(size_t size) { extern "C" void __RPC_USER MIDL_user_free(void *pointer) { free(pointer); } extern "C" error_status_t __stdcall testIfRunning(void) { - return cthulhu_is_available() ? ERROR_SUCCESS : ERROR_SERVICE_NOT_ACTIVE; + return reader_is_available() ? ERROR_SUCCESS : ERROR_SERVICE_NOT_ACTIVE; } extern "C" error_status_t __stdcall speakText(const wchar_t *text) { const std::string utf8 = to_utf8(text); @@ -920,7 +922,8 @@ extern "C" error_status_t __stdcall speakSsml(const wchar_t *ssml, SYMBOL_LEVEL, const std::string utf8 = to_utf8(ssml); GError *error = nullptr; GVariant *reply = g_dbus_connection_call_sync( - connection, BUS_NAME, OBJECT_PATH, INTERFACE_NAME, "SpeakSsml", + connection, readerEndpoint.busName, readerEndpoint.objectPath, + readerEndpoint.interfaceName, "SpeakSsml", g_variant_new("(ss)", "wine-nvda-controller", utf8.c_str()), G_VARIANT_TYPE("(i)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, &error); @@ -945,6 +948,24 @@ extern "C" error_status_t __stdcall isSpeaking(boolean *speaking) { int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { const wchar_t *commandLine = GetCommandLineW(); + int argumentCount = 0; + wchar_t **arguments = CommandLineToArgvW(commandLine, &argumentCount); + if (arguments == nullptr) return 1; + bool validReader = true; + for (int index = 1; index < argumentCount; ++index) { + if (lstrcmpW(arguments[index], L"--reader") != 0) continue; + if (++index == argumentCount) { validReader = false; break; } + if (lstrcmpW(arguments[index], L"cthulhu") == 0) + readerEndpoint = CthulhuEndpoint; + else if (lstrcmpW(arguments[index], L"orca") == 0) + readerEndpoint = OrcaEndpoint; + else { validReader = false; break; } + } + LocalFree(arguments); + if (!validReader) { + std::fprintf(stderr, "wine-access: --reader requires cthulhu or orca\n"); + return 1; + } const bool dialogReaderEnabled = !wide_contains(commandLine, L"--no-dialog-reader"); const bool controllerEnabled = @@ -965,7 +986,10 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); if (error != nullptr) g_error_free(error); - if (connection == nullptr || !cthulhu_is_available()) { + if (connection == nullptr || !reader_is_available()) { + std::fprintf(stderr, "wine-access: reader service %s is unavailable\n", + readerEndpoint.busName); + if (connection != nullptr) g_object_unref(connection); CloseHandle(singleton); return 1; } @@ -983,7 +1007,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { } HWND window = nullptr; - reviewService.start(connection,reviewIdentities); + reviewService.start(connection,reviewIdentities,readerEndpoint); bool controllerActive = false; if (controllerEnabled && FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) { diff --git a/reader_endpoint.h b/reader_endpoint.h new file mode 100644 index 0000000..78a6a71 --- /dev/null +++ b/reader_endpoint.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once + +struct ReaderEndpoint { + const char *busName; + const char *objectPath; + const char *interfaceName; +}; + +inline constexpr ReaderEndpoint CthulhuEndpoint{ + "org.stormux.Cthulhu1.Service", "/org/stormux/Cthulhu1/Service", + "org.stormux.Cthulhu1.Service"}; +inline constexpr ReaderEndpoint OrcaEndpoint{ + "org.gnome.Orca1.Service", "/org/gnome/Orca1/Service", + "org.gnome.Orca1.Service"}; diff --git a/review_service.cpp b/review_service.cpp index 884cbe6..0220d9d 100644 --- a/review_service.cpp +++ b/review_service.cpp @@ -8,8 +8,6 @@ #include namespace { -constexpr const char *reader = "org.stormux.Cthulhu1.Service"; -constexpr const char *reader_path = "/org/stormux/Cthulhu1/Service"; constexpr const char *path = "/org/stormux/Cthulhu1/WineReview"; constexpr const char *xml = R"( @@ -72,13 +70,14 @@ struct Job { } // namespace struct ReviewService::State { - explicit State(GDBusConnection *c,ReviewIdentityStore &s):connection(G_DBUS_CONNECTION(g_object_ref(c))),store(s) { + explicit State(GDBusConnection *c,ReviewIdentityStore &s,const ReaderEndpoint &reader):connection(G_DBUS_CONNECTION(g_object_ref(c))),store(s),endpoint(reader) { context=g_main_context_new(); loop=g_main_loop_new(context,FALSE); calls=g_cancellable_new(); } ~State() { g_object_unref(calls); g_main_loop_unref(loop); g_main_context_unref(context); g_object_unref(connection); } GDBusConnection *connection; ReviewIdentityStore store; + const ReaderEndpoint endpoint; GMainContext *context; GMainLoop *loop; GCancellable *calls; @@ -106,7 +105,7 @@ struct ReviewService::State { std::string lookup_owner() { GError *error=nullptr; GVariant *value=g_dbus_connection_call_sync(connection,"org.freedesktop.DBus","/org/freedesktop/DBus", - "org.freedesktop.DBus","GetNameOwner",g_variant_new("(s)",reader),G_VARIANT_TYPE("(s)"), + "org.freedesktop.DBus","GetNameOwner",g_variant_new("(s)",endpoint.busName),G_VARIANT_TYPE("(s)"), G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error); std::string result; if (value) { const gchar *name=nullptr; g_variant_get(value,"(&s)",&name); result=name; g_variant_unref(value); } @@ -145,7 +144,7 @@ struct ReviewService::State { if (now lock(mutex); notice=focus; } if (!registered || notice.focus.empty() || same_notice(notice,sent)) return; sent=notice; announcing=true; - g_dbus_connection_call(connection,owner.c_str(),reader_path,reader,"PresentWineReviewFocus", + g_dbus_connection_call(connection,owner.c_str(),endpoint.objectPath,endpoint.interfaceName,"PresentWineReviewFocus", g_variant_new("(ssstssb(iiii))",notice.instance.c_str(),notice.window.c_str(),notice.focus.c_str(),notice.revision, notice.title.c_str(),notice.role.c_str(),notice.protected_text,notice.bounds.left,notice.bounds.top, notice.bounds.right-notice.bounds.left,notice.bounds.bottom-notice.bounds.top), @@ -167,7 +166,7 @@ struct ReviewService::State { auto &state=*static_cast(data); const uint64_t received=GetTickCount64(); if (!state.same_user || state.owner!=sender || state.lookup_owner()!=sender) { - g_dbus_method_invocation_return_dbus_error(invocation,"org.freedesktop.DBus.Error.AccessDenied","Only the current Cthulhu service may capture Wine review."); return; + g_dbus_method_invocation_return_dbus_error(invocation,"org.freedesktop.DBus.Error.AccessDenied","Only the current selected reader service may capture Wine review."); return; } const char *window=nullptr,*focus=nullptr; guint64 revision=0; g_variant_get(parameters,"(&s&st)",&window,&focus,&revision); @@ -224,9 +223,9 @@ DWORD WINAPI ReviewService::State::work(LPVOID pointer) { { std::lock_guard lock(state->mutex); state->worker_done=true; state->changed.notify_all(); } return 0; } -bool ReviewService::start(GDBusConnection *connection,ReviewIdentityStore &store) { +bool ReviewService::start(GDBusConnection *connection,ReviewIdentityStore &store,const ReaderEndpoint &endpoint) { if (state_) return false; - state_=std::make_shared(connection,store); + state_=std::make_shared(connection,store,endpoint); auto state=state_; auto worker_state=new std::shared_ptr(state); worker_=CreateThread(nullptr,0,State::work,worker_state,0,nullptr); @@ -267,7 +266,7 @@ DWORD WINAPI ReviewService::State::dispatch(LPVOID pointer) { },new std::shared_ptr(state),[](gpointer p) { delete static_cast*>(p); }); g_source_attach(tick,state->context); state->owner_subscription=g_dbus_connection_signal_subscribe(state->connection,"org.freedesktop.DBus","org.freedesktop.DBus", - "NameOwnerChanged","/org/freedesktop/DBus",reader,G_DBUS_SIGNAL_FLAGS_NONE, + "NameOwnerChanged","/org/freedesktop/DBus",state->endpoint.busName,G_DBUS_SIGNAL_FLAGS_NONE, [](GDBusConnection*,const gchar*,const gchar*,const gchar*,const gchar*,GVariant*,gpointer p) { static_cast(p)->refresh_owner(); },state.get(),nullptr); diff --git a/review_service.h b/review_service.h index 70e18fe..3c77f2f 100644 --- a/review_service.h +++ b/review_service.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #pragma once #include "review_capture.h" +#include "reader_endpoint.h" #include #include @@ -8,7 +9,7 @@ class ReviewService { public: ReviewService() = default; ~ReviewService(); - bool start(GDBusConnection *, ReviewIdentityStore &); + bool start(GDBusConnection *, ReviewIdentityStore &, const ReaderEndpoint &); void focus_changed(const ReviewFocus &); void stop(); private: diff --git a/tests/README.md b/tests/README.md index deee8a6..6b2d877 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,36 @@ # Wine control integration +## Reader endpoint selection + +After building, run these checks sequentially on private displays: + +```sh +xvfb-run -a python3 tests/controller_integration.py --build-dir build +xvfb-run -a python3 tests/controller_integration.py --build-dir build --reader orca +xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca +xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca --fixture edit +xvfb-run -a python3 tests/review_integration.py --build-dir build --reader orca +``` + +The controller test drives the helper through a native Windows NVDA RPC client. +It verifies speech, cancellation, braille, SSML status forwarding, reader loss, +invalid reader values, and startup refusal when only the unselected service +exists. Both service names are owned while the selected reader handles requests. +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 +registration and capture access move to the new owner. Its provider object path +and interface remain the same for both readers. + +These tests supply recording services implementing the Wine protocol; they do +not run Orca or prove audible speech or physical braille. Stock Orca still needs +the reader-side methods and helper lifecycle described in the +[integration contract](../README.md#reader-selection-and-orca-integration). +Before submitting, check Cthulhu's ordinary Wine menus, editor arrows, repeated +characters, selections, deletion, protected fields and keypad review with live +speech and braille. When an Orca integration exists, repeat those workflows +with `--reader orca`, including return to Linux controls and reader restart. + ## Virtual desktop focus recovery `xvfb-run -a ctest --test-dir build -R foreground-focus --output-on-failure` diff --git a/tests/controller_fixture.c b/tests/controller_fixture.c new file mode 100644 index 0000000..9ff3e29 --- /dev/null +++ b/tests/controller_fixture.c @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include +#include +#include +#include + +// The test client IDL supplies an explicit local binding; its wire interfaces +// and method signatures are otherwise identical to the helper's IDL. +#include "nvdaController_c.c" + +void *__RPC_USER MIDL_user_allocate(size_t size) { return malloc(size); } +void __RPC_USER MIDL_user_free(void *pointer) { free(pointer); } + +static int check_status(const char *operation, error_status_t status) { + if (status == ERROR_SUCCESS) return 0; + fprintf(stderr, "%s failed: %lu\n", operation, (unsigned long)status); + return 1; +} + +int main(int argc, char **argv) { + RPC_WSTR bindingText = NULL; + RPC_BINDING_HANDLE binding = NULL; + if (RpcStringBindingComposeW(NULL, (RPC_WSTR)L"ncalrpc", NULL, + (RPC_WSTR)L"NvdaCtlr", NULL, &bindingText) || + RpcBindingFromStringBindingW(bindingText, &binding)) + return 1; + RpcStringFreeW(&bindingText); + controllerBinding = binding; + ULONGLONG deadline = GetTickCount64() + 5000; + while (RpcMgmtIsServerListening(binding) != RPC_S_OK) { + if (GetTickCount64() >= deadline) { + fprintf(stderr, "controller RPC server did not become ready\n"); + RpcBindingFree(&binding); + return 1; + } + Sleep(10); + } + int failed = 0; + if (argc > 1 && strcmp(argv[1], "unavailable") == 0) { + failed = testIfRunning() != ERROR_SERVICE_NOT_ACTIVE; + } else { + failed |= check_status("testIfRunning", testIfRunning()); + failed |= check_status("speakText", speakText(L"Controller speech")); + failed |= check_status("cancelSpeech", cancelSpeech()); + failed |= check_status("brailleMessage", brailleMessage(L"Controller braille")); + // The reader's SSML status must reach the caller without conversion. + failed |= speakSsml(L"Controller SSML", SYMBOL_LEVEL_ALL, + SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_CALL_NOT_IMPLEMENTED; + } + RpcBindingFree(&binding); + return failed; +} diff --git a/tests/controller_integration.py b/tests/controller_integration.py new file mode 100644 index 0000000..0e19be8 --- /dev/null +++ b/tests/controller_integration.py @@ -0,0 +1,119 @@ +"""Check NVDA RPC routing against private reader D-Bus services.""" +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")) +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), + *(["--reader", args.reader] if args.reader else [])])) + +interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" +otherInterface = "org.stormux.Cthulhu1.Service" if args.reader == "orca" else "org.gnome.Orca1.Service" +objectPath = "/" + interface.replace(".", "/") +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 += '' +events = [] + + +def record(connection, sender, path, iface, method, parameters, invocation): + events.append((method, parameters.unpack())) + invocation.return_value(GLib.Variant("(i)", (120,)) if method == "SpeakSsml" + else 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) + + +def name_call(method, name): + parameters = GLib.Variant("(su)", (name, 0)) if method == "RequestName" else GLib.Variant("(s)", (name,)) + return connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", method, parameters, None, + Gio.DBusCallFlags.NONE, 1000, None).unpack()[0] + + +def wait_for_client(client): + deadline = time.monotonic() + 10 + while client.poll() is None and time.monotonic() < deadline: + while GLib.MainContext.default().iteration(False): + pass + time.sleep(.005) + assert client.poll() == 0, f"controller client failed or timed out: {client.poll()}" + + +with tempfile.TemporaryDirectory(prefix="wine-access-controller-") 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") + source = Path(__file__).resolve().parent.parent + # Like NVDA's clients, bind directly to the local endpoint. WIDL's default + # automatic binding is unsupported by Wine's client NDR interpreter. + clientIdl = tempPath / "nvdaController.idl" + clientIdl.write_text((source / "nvdaController.idl").read_text().replace( + "\n uuid(", "\n implicit_handle(handle_t controllerBinding),\n uuid(")) + subprocess.run(["widl", "--win64", "-c", "-o", str(tempPath / "nvdaController_c.c"), + str(clientIdl)], check=True) + clientPath = tempPath / "controller-fixture.exe" + subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-I" + str(tempPath), + "-I" + str(build / "generated"), "-o", str(clientPath), + str(source / "tests/controller_fixture.c"), "-lrpcrt4"], check=True) + helper = None + client = None + helperCommand = [str(build / "cthulhu-wine-access.exe"), "--no-dialog-reader", + *(["--reader", args.reader] if args.reader else [])] + try: + subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45) + # Even when the other reader exists, the selected reader is required. + assert name_call("RequestName", otherInterface) == 1 + result = subprocess.run(helperCommand, env=env, timeout=5) + assert result.returncode == 1, "helper accepted an unselected reader" + for readerArgs in (["--reader"], ["--reader", "unknown"]): + result = subprocess.run([str(build / "cthulhu-wine-access.exe"), *readerArgs], + env=env, timeout=5) + assert result.returncode == 1, "invalid reader argument was accepted" + assert name_call("RequestName", interface) == 1 + helper = subprocess.Popen(helperCommand, env=env) + # The fixture waits for the RPC server to listen before making calls. + client = subprocess.Popen(["wine", str(clientPath)], env=env) + wait_for_client(client) + assert helper.poll() is None, "helper exited during controller requests" + assert events == [ + ("SpeakText", ("Controller speech", False)), + ("CancelSpeech", ()), + ("BrailleMessage", ("Controller braille",)), + ("SpeakSsml", ("wine-nvda-controller", "Controller SSML")), + ], events + assert name_call("ReleaseName", interface) == 1 + client = subprocess.Popen(["wine", str(clientPath), "unavailable"], env=env) + wait_for_client(client) + print(f"PASS: {interface} controller routing, SSML status, service loss, and reader validation") + finally: + for child in (client, helper): + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=3) + subprocess.run(["wineserver", "-k"], env=env, timeout=5) diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 26768cb..e94b264 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -17,6 +17,7 @@ from gi.repository import Gio, GLib parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") +parser.add_argument("--reader", choices=("cthulhu", "orca")) parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu") parser.add_argument("--notepadpp-exe", type=Path) parser.add_argument("--goldwave-exe", type=Path) @@ -37,14 +38,16 @@ if not args.session: sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), "--fixture", args.fixture, + *(["--reader", args.reader] if args.reader else []), *(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe else []), *(["--goldwave-exe", str(args.goldwave_exe.resolve())] if args.goldwave_exe else []), *(["--32-bit"] if args.is32Bit else []), *(["--late-helper"] if args.late_helper else []), *(["--restart-helper"] if args.restart_helper else [])])) -interface = "org.stormux.Cthulhu1.Service" -object_path = "/org/stormux/Cthulhu1/Service" +interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" +object_path = "/" + interface.replace(".", "/") +readerArguments = ["--reader", args.reader] if args.reader else [] xml = '' for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]): xml += f'' @@ -107,7 +110,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: while not ready.exists() and app.poll() is None and time.monotonic() < deadline: time.sleep(0.01) assert ready.exists(), "editor did not become ready before helper attachment" - helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env) + helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller", *readerArguments], env=env) # Allow the helper to register its bridge before opening the fixture. deadline = time.monotonic() + 1 while time.monotonic() < deadline: @@ -125,7 +128,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: if args.restart_helper and not restarted and any(e[0].endswith(":text") for e in events): helper.terminate() helper.wait(timeout=5) - helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env) + helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller", *readerArguments], env=env) restarted = True time.sleep(0.01) assert app.poll() == 0, "fixture failed or timed out" diff --git a/tests/review_integration.py b/tests/review_integration.py index 7fd609d..e53ce49 100644 --- a/tests/review_integration.py +++ b/tests/review_integration.py @@ -12,13 +12,17 @@ parser = argparse.ArgumentParser() parser.add_argument('--build-dir', default='build') parser.add_argument('--32-bit', dest='is32', action='store_true') parser.add_argument('--session', action='store_true') +parser.add_argument('--reader', choices=('cthulhu', 'orca')) 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), *(['--32-bit'] if args.is32 else [])])) -iface = 'org.stormux.Cthulhu1.Service' -path = '/org/stormux/Cthulhu1/Service' + '--session', '--build-dir', str(build), + *(['--reader', args.reader] if args.reader else []), + *(['--32-bit'] if args.is32 else [])])) +iface = 'org.gnome.Orca1.Service' if args.reader == 'orca' else 'org.stormux.Cthulhu1.Service' +path = '/' + iface.replace('.', '/') +readerArguments = ['--reader', args.reader] if args.reader else [] capture_iface = 'org.stormux.Cthulhu1.WineReview' capture_path = '/org/stormux/Cthulhu1/WineReview' @@ -85,7 +89,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary: return result[0] try: subprocess.run(['wineboot','-u'],env=env,check=True,timeout=45) - helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env) + helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller',*readerArguments],env=env) app = subprocess.Popen(['wine',str(fixture)],env=env) pump_until(lambda: any(f[4]=='Wine review fixture' for f in focuses.values()),8) reply, elapsed = capture() @@ -110,11 +114,37 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary: print('PASS: test_capture_wire_contract') other = Gio.DBusConnection.new_for_address_sync(os.environ['DBUS_SESSION_BUS_ADDRESS'], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT|Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,None,None) + # Owning the unselected reader name must not authorize capture. + otherIface = 'org.stormux.Cthulhu1.Service' if args.reader == 'orca' else 'org.gnome.Orca1.Service' + other.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','RequestName', + GLib.Variant('(su)',(otherIface,0)),None,Gio.DBusCallFlags.NONE,1000,None) sender,focus = next((s,f) for s,f in focuses.items() if f[4]=='Wine review fixture') wrong = request(other,sender,focus); pump_until(lambda:bool(wrong),2) assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0]) - other.close_sync(None) print('PASS: test_wrong_caller') + # Move the selected name to a new connection. The helper must register + # again, authorize the new owner, and immediately reject the old owner. + other.register_object(path, Gio.DBusNodeInfo.new_for_xml(interface_xml()).interfaces[0],record,None,None) + providers.clear(); focuses.clear() + connection.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','ReleaseName', + GLib.Variant('(s)',(iface,)),None,Gio.DBusCallFlags.NONE,1000,None) + other.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','RequestName', + GLib.Variant('(su)',(iface,0)),None,Gio.DBusCallFlags.NONE,1000,None) + pump_until(lambda:sender in providers and sender in focuses) + focus=focuses[sender] + wrong=request(connection,sender,focus); pump_until(lambda:bool(wrong),2) + assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0]) + accepted=request(other,sender,focus); pump_until(lambda:bool(accepted),2) + assert not isinstance(accepted[0],GLib.Error), accepted[0] + assert accepted[0][0].unpack()[0]['status']=='ok' + providers.clear(); focuses.clear() + other.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','ReleaseName', + GLib.Variant('(s)',(iface,)),None,Gio.DBusCallFlags.NONE,1000,None) + connection.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','RequestName', + GLib.Variant('(su)',(iface,0)),None,Gio.DBusCallFlags.NONE,1000,None) + pump_until(lambda:sender in providers and sender in focuses) + other.close_sync(None) + print('PASS: test_reader_owner_replacement') configure(33) reply,_ = capture(); data=reply.unpack()[0] assert data['status']=='ok', data['status'] @@ -152,7 +182,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary: helper.terminate(); helper.wait(timeout=3) subprocess.run(['wine',str(fixture),'--check-remote'],env=env,check=True,timeout=5) print('PASS: test_native_buffer_lifetime_after_helper_stop') - helper=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env) + helper=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller',*readerArguments],env=env) pump_until(lambda:any(s != sender and f[4]=='Wine review fixture' for s,f in focuses.items()),5) focuses.pop(sender,None) configure(7) @@ -180,7 +210,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary: connection.unregister_object(registration) registration = connection.register_object(path,Gio.DBusNodeInfo.new_for_xml(interface_xml(False)).interfaces[0],record,None,None) events.clear() - helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env) + helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller',*readerArguments],env=env) end=time.monotonic()+1 while time.monotonic()