Make d-bus available to orca if someone decides they want to implement it.

This commit is contained in:
Storm Dragon committed 2026-10-12 04:37:12 -04:00
1 parent c3e981b4c2
commit 0f59809e9a
11 files changed
+374 -39

No files matched your search

+1 -1
View File
@@ -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
$<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>)
+66 -5
View File
@@ -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;
+35 -11
View File
@@ -9,12 +9,14 @@
#include "native_items.h"
#include "review_service.h"
#include "foreground_focus.h"
#include "reader_endpoint.h"
#include <commctrl.h>
#include <gio/gio.h>
#include <oleacc.h>
#include <rpc.h>
#include <windows.h>
#include <shellapi.h>
#include <cstdint>
#include <bit>
@@ -25,9 +27,7 @@
#include <string>
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) {
+15
View File
@@ -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"};
+9 -10
View File
@@ -8,8 +8,6 @@
#include <unistd.h>
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"(<node><interface name="org.stormux.Cthulhu1.WineReview">
<method name="CaptureWindow"><arg type="s" direction="in"/><arg type="s" direction="in"/>
@@ -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<next_registration) return;
next_registration=now+1000;
GError *error=nullptr;
GVariant *value=g_dbus_connection_call_sync(connection,owner.c_str(),reader_path,reader,"RegisterWineReviewProvider",
GVariant *value=g_dbus_connection_call_sync(connection,owner.c_str(),endpoint.objectPath,endpoint.interfaceName,"RegisterWineReviewProvider",
g_variant_new("(ou)",path,1u),G_VARIANT_TYPE("(b)"),G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error);
gboolean accepted=FALSE;
if (value) { g_variant_get(value,"(b)",&accepted); g_variant_unref(value); }
@@ -156,7 +155,7 @@ struct ReviewService::State {
{ std::lock_guard<std::mutex> 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<State*>(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<std::mutex> 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<State>(connection,store);
state_=std::make_shared<State>(connection,store,endpoint);
auto state=state_;
auto worker_state=new std::shared_ptr<State>(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>(state),[](gpointer p) { delete static_cast<std::shared_ptr<State>*>(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<State*>(p)->refresh_owner();
},state.get(),nullptr);
+2 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include "review_capture.h"
#include "reader_endpoint.h"
#include <gio/gio.h>
#include <memory>
@@ -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:
+31
View File
@@ -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`
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <rpc.h>
// 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"<speak>Controller SSML</speak>", SYMBOL_LEVEL_ALL,
SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_CALL_NOT_IMPLEMENTED;
}
RpcBindingFree(&binding);
return failed;
}
+119
View File
@@ -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 = '<node><interface name="' + interface + '">'
for method, (inputs, output) in methods.items():
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in inputs)
xml += '<arg type="' + output + '" direction="out"/></method>'
xml += '</interface></node>'
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", "<speak>Controller SSML</speak>")),
], 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)
+7 -4
View File
@@ -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 = '<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"/>'
@@ -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"
+37 -7
View File
@@ -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()<end:
while GLib.MainContext.default().iteration(False): pass