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_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}") target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
target_link_libraries(cthulhu-wine-access PRIVATE 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_link_options(cthulhu-wine-access PRIVATE -mwindows)
target_compile_options(cthulhu-wine-access PRIVATE target_compile_options(cthulhu-wine-access PRIVATE
$<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>) $<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>)
+66 -5
View File
@@ -1,7 +1,9 @@
# Wine Access # Wine Access
`wine-access` is the standalone Wine-hosted accessibility helper for Cthulhu `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: 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 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 invisible; the important endpoint is the local Wine RPC server registered by
the helper. The helper forwards speech, braille, cancellation, and standard 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 The executable is named `cthulhu-wine-access.exe` for compatibility with the
Cthulhu-Go supervisor. 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 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; loader for the same user and prefix. It requires the selected reader service
it exits if `org.stormux.Cthulhu1.Service` is unavailable. 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 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 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 `PresentWineReviewFocus(ssstssb(iiii)) -> b` to the reader service. The helper
exports `/org/stormux/Cthulhu1/WineReview`, interface exports `/org/stormux/Cthulhu1/WineReview`, interface
`org.stormux.Cthulhu1.WineReview`, with `CaptureWindow(sst) -> a{sv}`. Only the `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`, `version:u`, `instance:s`, `window:s`, `focus:s`, `revision:t`, `status:s`,
`bounds:(iiii)`, and `nodes:aa{sv}`. Nodes have `id`, `parent`, `role`, `name` `bounds:(iiii)`, and `nodes:aa{sv}`. Nodes have `id`, `parent`, `role`, `name`
strings; `protected`, `visible`, `text_known`, `caret_known` booleans; strings; `protected`, `visible`, `text_known`, `caret_known` booleans;
+35 -11
View File
@@ -9,12 +9,14 @@
#include "native_items.h" #include "native_items.h"
#include "review_service.h" #include "review_service.h"
#include "foreground_focus.h" #include "foreground_focus.h"
#include "reader_endpoint.h"
#include <commctrl.h> #include <commctrl.h>
#include <gio/gio.h> #include <gio/gio.h>
#include <oleacc.h> #include <oleacc.h>
#include <rpc.h> #include <rpc.h>
#include <windows.h> #include <windows.h>
#include <shellapi.h>
#include <cstdint> #include <cstdint>
#include <bit> #include <bit>
@@ -25,9 +27,7 @@
#include <string> #include <string>
namespace { namespace {
constexpr auto BUS_NAME = "org.stormux.Cthulhu1.Service"; ReaderEndpoint readerEndpoint = CthulhuEndpoint;
constexpr auto OBJECT_PATH = "/org/stormux/Cthulhu1/Service";
constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service";
constexpr int DBUS_TIMEOUT_MS = 500; constexpr int DBUS_TIMEOUT_MS = 500;
GDBusConnection *connection = nullptr; GDBusConnection *connection = nullptr;
ReviewIdentityStore reviewIdentities; ReviewIdentityStore reviewIdentities;
@@ -112,7 +112,8 @@ DWORD call_boolean(const char *method, GVariant *parameters) {
return ERROR_SERVICE_NOT_ACTIVE; return ERROR_SERVICE_NOT_ACTIVE;
GError *error = nullptr; GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync( 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, G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error); &error);
if (error != nullptr) if (error != nullptr)
@@ -125,11 +126,12 @@ DWORD call_boolean(const char *method, GVariant *parameters) {
return result == TRUE ? ERROR_SUCCESS : ERROR_GEN_FAILURE; return result == TRUE ? ERROR_SUCCESS : ERROR_GEN_FAILURE;
} }
bool cthulhu_is_available() { bool reader_is_available() {
GError *error = nullptr; GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync( GVariant *reply = g_dbus_connection_call_sync(
connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", 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, G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error); &error);
if (error != nullptr) if (error != nullptr)
@@ -168,7 +170,7 @@ std::string accessible_property(
} }
// Standard-control queries follow the LGPL wine-a11y reader published by // 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) { void strip_mnemonics(wchar_t *text) {
wchar_t *output = text; wchar_t *output = text;
for (wchar_t *input = text; *input != L'\0'; ++input) { 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" void __RPC_USER MIDL_user_free(void *pointer) { free(pointer); }
extern "C" error_status_t __stdcall testIfRunning(void) { 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) { extern "C" error_status_t __stdcall speakText(const wchar_t *text) {
const std::string utf8 = to_utf8(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); const std::string utf8 = to_utf8(ssml);
GError *error = nullptr; GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync( 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_new("(ss)", "wine-nvda-controller", utf8.c_str()),
G_VARIANT_TYPE("(i)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr, G_VARIANT_TYPE("(i)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error); &error);
@@ -945,6 +948,24 @@ extern "C" error_status_t __stdcall isSpeaking(boolean *speaking) {
int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
const wchar_t *commandLine = GetCommandLineW(); 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 = const bool dialogReaderEnabled =
!wide_contains(commandLine, L"--no-dialog-reader"); !wide_contains(commandLine, L"--no-dialog-reader");
const bool controllerEnabled = 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); connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
if (error != nullptr) if (error != nullptr)
g_error_free(error); 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); CloseHandle(singleton);
return 1; return 1;
} }
@@ -983,7 +1007,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
} }
HWND window = nullptr; HWND window = nullptr;
reviewService.start(connection,reviewIdentities); reviewService.start(connection,reviewIdentities,readerEndpoint);
bool controllerActive = false; bool controllerActive = false;
if (controllerEnabled && if (controllerEnabled &&
FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) { 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> #include <unistd.h>
namespace { 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 *path = "/org/stormux/Cthulhu1/WineReview";
constexpr const char *xml = R"(<node><interface name="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"/> <method name="CaptureWindow"><arg type="s" direction="in"/><arg type="s" direction="in"/>
@@ -72,13 +70,14 @@ struct Job {
} // namespace } // namespace
struct ReviewService::State { 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); context=g_main_context_new(); loop=g_main_loop_new(context,FALSE);
calls=g_cancellable_new(); calls=g_cancellable_new();
} }
~State() { g_object_unref(calls); g_main_loop_unref(loop); g_main_context_unref(context); g_object_unref(connection); } ~State() { g_object_unref(calls); g_main_loop_unref(loop); g_main_context_unref(context); g_object_unref(connection); }
GDBusConnection *connection; GDBusConnection *connection;
ReviewIdentityStore store; ReviewIdentityStore store;
const ReaderEndpoint endpoint;
GMainContext *context; GMainContext *context;
GMainLoop *loop; GMainLoop *loop;
GCancellable *calls; GCancellable *calls;
@@ -106,7 +105,7 @@ struct ReviewService::State {
std::string lookup_owner() { std::string lookup_owner() {
GError *error=nullptr; GError *error=nullptr;
GVariant *value=g_dbus_connection_call_sync(connection,"org.freedesktop.DBus","/org/freedesktop/DBus", 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); G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error);
std::string result; std::string result;
if (value) { const gchar *name=nullptr; g_variant_get(value,"(&s)",&name); result=name; g_variant_unref(value); } 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; if (now<next_registration) return;
next_registration=now+1000; next_registration=now+1000;
GError *error=nullptr; 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); g_variant_new("(ou)",path,1u),G_VARIANT_TYPE("(b)"),G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error);
gboolean accepted=FALSE; gboolean accepted=FALSE;
if (value) { g_variant_get(value,"(b)",&accepted); g_variant_unref(value); } 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; } { std::lock_guard<std::mutex> lock(mutex); notice=focus; }
if (!registered || notice.focus.empty() || same_notice(notice,sent)) return; if (!registered || notice.focus.empty() || same_notice(notice,sent)) return;
sent=notice; announcing=true; 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, 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.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), 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); auto &state=*static_cast<State*>(data);
const uint64_t received=GetTickCount64(); const uint64_t received=GetTickCount64();
if (!state.same_user || state.owner!=sender || state.lookup_owner()!=sender) { 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; const char *window=nullptr,*focus=nullptr; guint64 revision=0;
g_variant_get(parameters,"(&s&st)",&window,&focus,&revision); 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(); } { std::lock_guard<std::mutex> lock(state->mutex); state->worker_done=true; state->changed.notify_all(); }
return 0; return 0;
} }
bool ReviewService::start(GDBusConnection *connection,ReviewIdentityStore &store) { bool ReviewService::start(GDBusConnection *connection,ReviewIdentityStore &store,const ReaderEndpoint &endpoint) {
if (state_) return false; if (state_) return false;
state_=std::make_shared<State>(connection,store); state_=std::make_shared<State>(connection,store,endpoint);
auto state=state_; auto state=state_;
auto worker_state=new std::shared_ptr<State>(state); auto worker_state=new std::shared_ptr<State>(state);
worker_=CreateThread(nullptr,0,State::work,worker_state,0,nullptr); 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); }); },new std::shared_ptr<State>(state),[](gpointer p) { delete static_cast<std::shared_ptr<State>*>(p); });
g_source_attach(tick,state->context); g_source_attach(tick,state->context);
state->owner_subscription=g_dbus_connection_signal_subscribe(state->connection,"org.freedesktop.DBus","org.freedesktop.DBus", 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) { [](GDBusConnection*,const gchar*,const gchar*,const gchar*,const gchar*,GVariant*,gpointer p) {
static_cast<State*>(p)->refresh_owner(); static_cast<State*>(p)->refresh_owner();
},state.get(),nullptr); },state.get(),nullptr);
+2 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later // SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once #pragma once
#include "review_capture.h" #include "review_capture.h"
#include "reader_endpoint.h"
#include <gio/gio.h> #include <gio/gio.h>
#include <memory> #include <memory>
@@ -8,7 +9,7 @@ class ReviewService {
public: public:
ReviewService() = default; ReviewService() = default;
~ReviewService(); ~ReviewService();
bool start(GDBusConnection *, ReviewIdentityStore &); bool start(GDBusConnection *, ReviewIdentityStore &, const ReaderEndpoint &);
void focus_changed(const ReviewFocus &); void focus_changed(const ReviewFocus &);
void stop(); void stop();
private: private:
+31
View File
@@ -1,5 +1,36 @@
# Wine control integration # 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 ## Virtual desktop focus recovery
`xvfb-run -a ctest --test-dir build -R foreground-focus --output-on-failure` `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 = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build") parser.add_argument("--build-dir", default="build")
parser.add_argument("--session", action="store_true") 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("--fixture", choices=("menu", "edit", "notepad", "list", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu")
parser.add_argument("--notepadpp-exe", type=Path) parser.add_argument("--notepadpp-exe", type=Path)
parser.add_argument("--goldwave-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, sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build), __file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture, "--fixture", args.fixture,
*(["--reader", args.reader] if args.reader else []),
*(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe 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 []), *(["--goldwave-exe", str(args.goldwave_exe.resolve())] if args.goldwave_exe else []),
*(["--32-bit"] if args.is32Bit else []), *(["--32-bit"] if args.is32Bit else []),
*(["--late-helper"] if args.late_helper else []), *(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])])) *(["--restart-helper"] if args.restart_helper else [])]))
interface = "org.stormux.Cthulhu1.Service" interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
object_path = "/org/stormux/Cthulhu1/Service" object_path = "/" + interface.replace(".", "/")
readerArguments = ["--reader", args.reader] if args.reader else []
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">' xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]): for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]):
xml += f'<arg name="arg{index}" type="{kind}" direction="in"/>' 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: while not ready.exists() and app.poll() is None and time.monotonic() < deadline:
time.sleep(0.01) time.sleep(0.01)
assert ready.exists(), "editor did not become ready before helper attachment" 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. # Allow the helper to register its bridge before opening the fixture.
deadline = time.monotonic() + 1 deadline = time.monotonic() + 1
while time.monotonic() < deadline: 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): if args.restart_helper and not restarted and any(e[0].endswith(":text") for e in events):
helper.terminate() helper.terminate()
helper.wait(timeout=5) 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 restarted = True
time.sleep(0.01) time.sleep(0.01)
assert app.poll() == 0, "fixture failed or timed out" 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('--build-dir', default='build')
parser.add_argument('--32-bit', dest='is32', action='store_true') parser.add_argument('--32-bit', dest='is32', action='store_true')
parser.add_argument('--session', action='store_true') parser.add_argument('--session', action='store_true')
parser.add_argument('--reader', choices=('cthulhu', 'orca'))
args = parser.parse_args() args = parser.parse_args()
build = Path(args.build_dir).resolve() build = Path(args.build_dir).resolve()
if not args.session: if not args.session:
sys.exit(subprocess.call(['dbus-run-session', '--', sys.executable, __file__, sys.exit(subprocess.call(['dbus-run-session', '--', sys.executable, __file__,
'--session', '--build-dir', str(build), *(['--32-bit'] if args.is32 else [])])) '--session', '--build-dir', str(build),
iface = 'org.stormux.Cthulhu1.Service' *(['--reader', args.reader] if args.reader else []),
path = '/org/stormux/Cthulhu1/Service' *(['--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_iface = 'org.stormux.Cthulhu1.WineReview'
capture_path = '/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] return result[0]
try: try:
subprocess.run(['wineboot','-u'],env=env,check=True,timeout=45) 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) app = subprocess.Popen(['wine',str(fixture)],env=env)
pump_until(lambda: any(f[4]=='Wine review fixture' for f in focuses.values()),8) pump_until(lambda: any(f[4]=='Wine review fixture' for f in focuses.values()),8)
reply, elapsed = capture() reply, elapsed = capture()
@@ -110,11 +114,37 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
print('PASS: test_capture_wire_contract') print('PASS: test_capture_wire_contract')
other = Gio.DBusConnection.new_for_address_sync(os.environ['DBUS_SESSION_BUS_ADDRESS'], other = Gio.DBusConnection.new_for_address_sync(os.environ['DBUS_SESSION_BUS_ADDRESS'],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT|Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,None,None) 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') 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) wrong = request(other,sender,focus); pump_until(lambda:bool(wrong),2)
assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0]) assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0])
other.close_sync(None)
print('PASS: test_wrong_caller') 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) configure(33)
reply,_ = capture(); data=reply.unpack()[0] reply,_ = capture(); data=reply.unpack()[0]
assert data['status']=='ok', data['status'] 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) helper.terminate(); helper.wait(timeout=3)
subprocess.run(['wine',str(fixture),'--check-remote'],env=env,check=True,timeout=5) subprocess.run(['wine',str(fixture),'--check-remote'],env=env,check=True,timeout=5)
print('PASS: test_native_buffer_lifetime_after_helper_stop') 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) pump_until(lambda:any(s != sender and f[4]=='Wine review fixture' for s,f in focuses.items()),5)
focuses.pop(sender,None) focuses.pop(sender,None)
configure(7) configure(7)
@@ -180,7 +210,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
connection.unregister_object(registration) connection.unregister_object(registration)
registration = connection.register_object(path,Gio.DBusNodeInfo.new_for_xml(interface_xml(False)).interfaces[0],record,None,None) registration = connection.register_object(path,Gio.DBusNodeInfo.new_for_xml(interface_xml(False)).interfaces[0],record,None,None)
events.clear() 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 end=time.monotonic()+1
while time.monotonic()<end: while time.monotonic()<end:
while GLib.MainContext.default().iteration(False): pass while GLib.MainContext.default().iteration(False): pass