Make the process of adding an orca extension easier.

This commit is contained in:
Storm Dragon committed 2026-10-14 03:35:00 -04:00
1 parent a8118c12cf
commit 417ee40dda
6 files changed
+199 -35

No files matched your search

+24
View File
@@ -123,6 +123,30 @@ WINEPREFIX=/path/to/prefix /path/to/cthulhu-wine-access.exe --reader orca
| Cthulhu (default) | `org.stormux.Cthulhu1.Service` | `/org/stormux/Cthulhu1/Service` |
| Orca | `org.gnome.Orca1.Service` | `/org/gnome/Orca1/Service` |
An Orca user extension can expose the Wine methods on its own object and
interface, without adding them to Orca's core service. Override either field
with `--reader-path` and `--reader-interface`; omitted fields retain the
selected reader's defaults. For an extension class named `WineAccess`:
```sh
WINEPREFIX=/path/to/prefix /path/to/cthulhu-wine-access.exe --reader orca \
--reader-path /org/gnome/Orca1/Service/WineAccess \
--reader-interface org.gnome.Orca1.WineAccess
```
Both overrides accept validated D-Bus values and apply regardless of their
position relative to `--reader`. The bus name stays that of the selected
reader; overrides do not grant review access to another connection.
Before starting its hooks or controller server, the helper introspects the
selected object and interface and checks the required method signatures for
the enabled capabilities. A missing extension or incompatible interface
causes startup to fail with a diagnostic. Controller support requires
`SpeakText`, `CancelSpeech`, `BrailleMessage` and `SpeakSsml`; control reading
requires `PresentAccessibleEvent`. Review methods remain optional. The
interface must be published before launching the helper. This check runs at
startup; later request failures still follow the ordinary error handling.
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
+92 -16
View File
@@ -88,18 +88,6 @@ HWND combo_event_control(HWND window, DWORD event) {
return nullptr;
}
// Winegcc uses 16-bit wchar_t; Linux libc's wcs* functions expect 32-bit
// wchar_t. Use Wine's Unicode APIs or explicit code-unit comparisons.
bool wide_contains(const wchar_t *text, const wchar_t *needle) {
for (; *text != L'\0'; ++text) {
const wchar_t *left = text;
const wchar_t *right = needle;
while (*right != L'\0' && *left == *right) { ++left; ++right; }
if (*right == L'\0') return true;
}
return false;
}
struct ControlDescription {
std::string name;
std::string role;
@@ -162,6 +150,59 @@ bool reader_is_available() {
return owned == TRUE;
}
bool reader_supports_protocol(bool controllerEnabled, bool dialogReaderEnabled) {
GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync(
connection, readerEndpoint.busName, readerEndpoint.objectPath,
"org.freedesktop.DBus.Introspectable", "Introspect", nullptr,
G_VARIANT_TYPE("(s)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error);
if (error) g_error_free(error);
if (!reply) {
std::fprintf(stderr, "wine-access: reader object %s is unavailable\n",
readerEndpoint.objectPath);
return false;
}
const char *xml = nullptr;
g_variant_get(reply, "(&s)", &xml);
GDBusNodeInfo *node = g_dbus_node_info_new_for_xml(xml, nullptr);
g_variant_unref(reply);
GDBusInterfaceInfo *interfaceInfo = node ?
g_dbus_node_info_lookup_interface(node, readerEndpoint.interfaceName) : nullptr;
struct RequiredMethod {
const char *name;
const char *inputs;
const char *outputs;
bool required;
};
const RequiredMethod methods[] = {
{"SpeakText", "sb", "b", controllerEnabled},
{"CancelSpeech", "", "b", controllerEnabled},
{"BrailleMessage", "s", "b", controllerEnabled},
{"SpeakSsml", "ss", "i", controllerEnabled},
{"PresentAccessibleEvent", "sssssssiis", "b", dialogReaderEnabled}};
bool supported = interfaceInfo != nullptr;
if (!interfaceInfo)
std::fprintf(stderr, "wine-access: reader interface %s is unavailable\n",
readerEndpoint.interfaceName);
for (const auto &required : methods) {
if (!interfaceInfo || !required.required) continue;
GDBusMethodInfo *method = g_dbus_interface_info_lookup_method(interfaceInfo, required.name);
std::string inputs, outputs;
if (method) {
for (auto args = method->in_args; args && *args; ++args) inputs += (*args)->signature;
for (auto args = method->out_args; args && *args; ++args) outputs += (*args)->signature;
}
if (!method || inputs != required.inputs || outputs != required.outputs) {
std::fprintf(stderr, "wine-access: reader method %s requires %s -> %s\n",
required.name, required.inputs, required.outputs);
supported = false;
}
}
if (node) g_dbus_node_info_unref(node);
return supported;
}
// Standard-control queries follow the LGPL wine-a11y reader published by
// Mudb0y, while presentation uses the selected reader's D-Bus interface.
void strip_mnemonics(wchar_t *text) {
@@ -1008,7 +1049,32 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
wchar_t **arguments = CommandLineToArgvW(commandLine, &argumentCount);
if (arguments == nullptr) return 1;
bool validReader = true;
bool validEndpoint = true;
std::string readerPath, readerInterface;
bool dialogReaderEnabled = true, controllerEnabled = true;
for (int index = 1; index < argumentCount; ++index) {
if (lstrcmpW(arguments[index], L"--no-dialog-reader") == 0) {
dialogReaderEnabled = false;
continue;
}
if (lstrcmpW(arguments[index], L"--no-controller") == 0) {
controllerEnabled = false;
continue;
}
const bool pathOption = lstrcmpW(arguments[index], L"--reader-path") == 0;
const bool interfaceOption = lstrcmpW(arguments[index], L"--reader-interface") == 0;
if (pathOption || interfaceOption) {
if (++index == argumentCount) { validEndpoint = false; break; }
std::string value = to_utf8(arguments[index]);
if (pathOption ? !g_variant_is_object_path(value.c_str()) :
!g_dbus_is_interface_name(value.c_str())) {
validEndpoint = false;
break;
}
if (pathOption) readerPath = value;
else readerInterface = value;
continue;
}
if (lstrcmpW(arguments[index], L"--reader") != 0) continue;
if (++index == argumentCount) { validReader = false; break; }
if (lstrcmpW(arguments[index], L"cthulhu") == 0)
@@ -1022,10 +1088,14 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
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 =
!wide_contains(commandLine, L"--no-controller");
if (!validEndpoint) {
std::fprintf(stderr, "wine-access: --reader-path and --reader-interface require valid D-Bus values\n");
return 1;
}
// Apply overrides after reader selection, independent of argument order.
// These strings remain alive until the workers have stopped at shutdown.
if (!readerPath.empty()) readerEndpoint.objectPath = readerPath.c_str();
if (!readerInterface.empty()) readerEndpoint.interfaceName = readerInterface.c_str();
if (!dialogReaderEnabled && !controllerEnabled)
return 0;
@@ -1050,6 +1120,12 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
return 1;
}
if (!reader_supports_protocol(controllerEnabled, dialogReaderEnabled)) {
g_object_unref(connection);
CloseHandle(singleton);
return 1;
}
// Application-owned MSAA objects (including Windows Forms menus) are
// marshaled across processes. Initialize their callback/message-loop thread;
// Wine's built-in control proxies can otherwise hide this missing setup.
+17
View File
@@ -102,12 +102,29 @@ 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.
It also rejects invalid endpoint overrides, missing objects/interfaces,
missing required methods and incorrect input/output signatures.
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.
To check an Orca extension endpoint, add `--extension-endpoint`:
```sh
xvfb-run -a python3 tests/controller_integration.py --build-dir build --reader orca --extension-endpoint
xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca --extension-endpoint
xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca --extension-endpoint --fixture edit
xvfb-run -a python3 tests/review_integration.py --build-dir build --reader orca --extension-endpoint
```
These publish `/org/gnome/Orca1/Service/WineAccess` with interface
`org.gnome.Orca1.WineAccess`, while retaining the `org.gnome.Orca1.Service`
bus name. Controller checks put overrides before reader selection; control
and review checks put them after it. Review checks still reject unrelated
callers and transfer capture access only when the selected bus owner changes.
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
+46 -9
View File
@@ -12,17 +12,21 @@ 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("--extension-endpoint", action="store_true")
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),
*(["--extension-endpoint"] if args.extension_endpoint else []),
*(["--reader", args.reader] if args.reader else [])]))
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName
otherInterface = "org.stormux.Cthulhu1.Service" if args.reader == "orca" else "org.gnome.Orca1.Service"
objectPath = "/" + interface.replace(".", "/")
objectPath = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_endpoint else "")
endpointArguments = ["--reader-path", objectPath, "--reader-interface", interface] if args.extension_endpoint else []
methods = {"SpeakText": ("sb", "b"), "CancelSpeech": ("", "b"),
"BrailleMessage": ("s", "b"), "SpeakSsml": ("ss", "i")}
xml = '<node><interface name="' + interface + '">'
@@ -41,7 +45,7 @@ def record(connection, sender, path, iface, method, parameters, invocation):
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
registration = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
def name_call(method, name):
@@ -51,13 +55,13 @@ def name_call(method, name):
Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
def wait_for_client(client):
def wait_for_client(client, expected=0):
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()}"
assert client.poll() == expected, f"client failed or timed out: {client.poll()}"
with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
@@ -80,6 +84,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
helper = None
client = None
helperCommand = [str(build / "cthulhu-wine-access.exe"), "--no-dialog-reader",
*endpointArguments,
*(["--reader", args.reader] if args.reader else [])]
try:
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
@@ -87,11 +92,43 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
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"]):
for readerArgs in (["--reader"], ["--reader", "unknown"],
["--reader-path"], ["--reader-path", ""],
["--reader-path", "relative/path"], ["--reader-path", "/bad-path"],
["--reader-interface"], ["--reader-interface", ""],
["--reader-interface", "WineAccess"],
["--reader-interface", "org.example.Bad-Interface"]):
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
assert result.returncode == 1, "invalid endpoint argument was accepted"
assert name_call("RequestName", busName) == 1
# An owned reader name is insufficient: refuse missing objects,
# interfaces, methods and incompatible signatures before starting RPC.
def reject(command):
rejected = subprocess.Popen(command, env=env, stderr=subprocess.PIPE)
try:
wait_for_client(rejected, 1)
return rejected.communicate()[1].decode()
finally:
if rejected.poll() is None:
rejected.kill()
rejected.wait()
diagnostic = reject([*helperCommand, "--reader-path", "/Missing"])
# Gio can introspect an absent object as an empty node.
assert "reader object" in diagnostic or "reader interface" in diagnostic, diagnostic
assert "reader interface" in reject([*helperCommand, "--reader-interface", "org.example.Missing"])
connection.unregister_object(registration)
for brokenXml in (
xml.replace('<method name="CancelSpeech"><arg type="b" direction="out"/></method>', ''),
xml.replace('<arg type="i" direction="out"/>', '<arg type="b" direction="out"/>'),
xml.replace('<arg type="b" direction="in"/>', '<arg type="s" direction="in"/>')):
broken = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(brokenXml).interfaces[0], record, None, None)
assert "reader method" in reject(helperCommand)
connection.unregister_object(broken)
registration = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
# Controller-only interfaces must not enable control reading.
assert "PresentAccessibleEvent" in reject([part for part in helperCommand if part != "--no-dialog-reader"])
print("PASS: endpoint validation and required method signatures")
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)
@@ -103,7 +140,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
("BrailleMessage", ("Controller braille",)),
("SpeakSsml", ("wine-nvda-controller", "<speak>Controller SSML</speak>")),
], events
assert name_call("ReleaseName", interface) == 1
assert name_call("ReleaseName", busName) == 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")
+8 -3
View File
@@ -18,6 +18,7 @@ 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("--extension-endpoint", action="store_true")
parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "listbox", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu")
parser.add_argument("--notepadpp-exe", type=Path)
parser.add_argument("--goldwave-exe", type=Path)
@@ -38,6 +39,7 @@ if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture,
*(["--extension-endpoint"] if args.extension_endpoint else []),
*(["--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 []),
@@ -45,9 +47,12 @@ if not args.session:
*(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])]))
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
object_path = "/" + interface.replace(".", "/")
busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName
object_path = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_endpoint else "")
readerArguments = ["--reader", args.reader] if args.reader else []
if args.extension_endpoint:
readerArguments += ["--reader-path", object_path, "--reader-interface", interface]
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"/>'
@@ -62,7 +67,7 @@ connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(object_path, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "RequestName",
GLib.Variant("(su)", (interface, 0)), None,
GLib.Variant("(su)", (busName, 0)), None,
Gio.DBusCallFlags.NONE, 1000, None)
with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
+12 -7
View File
@@ -13,16 +13,21 @@ 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'))
parser.add_argument('--extension-endpoint', 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),
*(['--extension-endpoint'] if args.extension_endpoint else []),
*(['--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('.', '/')
busName = 'org.gnome.Orca1.Service' if args.reader == 'orca' else 'org.stormux.Cthulhu1.Service'
iface = busName.rsplit('.', 1)[0] + '.WineAccess' if args.extension_endpoint else busName
path = '/' + busName.replace('.', '/') + ('/WineAccess' if args.extension_endpoint else '')
readerArguments = ['--reader', args.reader] if args.reader else []
if args.extension_endpoint:
readerArguments += ['--reader-path', path, '--reader-interface', iface]
capture_iface = 'org.stormux.Cthulhu1.WineReview'
capture_path = '/org/stormux/Cthulhu1/WineReview'
@@ -50,7 +55,7 @@ def record(conn, sender, object_path, interface, method, params, invocation):
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
registration = connection.register_object(path, Gio.DBusNodeInfo.new_for_xml(interface_xml()).interfaces[0], record, None, 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)
GLib.Variant('(su)',(busName,0)),None,Gio.DBusCallFlags.NONE,1000,None)
def pump_until(predicate, seconds=5):
end = time.monotonic()+seconds
@@ -127,9 +132,9 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
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)
GLib.Variant('(s)',(busName,)),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)
GLib.Variant('(su)',(busName,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)
@@ -139,9 +144,9 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
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)
GLib.Variant('(s)',(busName,)),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)
GLib.Variant('(su)',(busName,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')