From 417ee40ddab1d1f20c25cd3cbdc218c866ec35ce Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 14 Oct 2026 03:35:00 -0400 Subject: [PATCH] Make the process of adding an orca extension easier. --- README.md | 24 +++++++ main.cpp | 108 +++++++++++++++++++++++++++----- tests/README.md | 17 +++++ tests/controller_integration.py | 55 +++++++++++++--- tests/menu_integration.py | 11 +++- tests/review_integration.py | 19 +++--- 6 files changed, 199 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 91f9c98..22bdb1a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.cpp b/main.cpp index f9b0048..9dc7345 100644 --- a/main.cpp +++ b/main.cpp @@ -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. diff --git a/tests/README.md b/tests/README.md index 0280a77..1def745 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 diff --git a/tests/controller_integration.py b/tests/controller_integration.py index 0e19be8..e5c73ca 100644 --- a/tests/controller_integration.py +++ b/tests/controller_integration.py @@ -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 = '' @@ -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('', ''), + xml.replace('', ''), + xml.replace('', '')): + 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", "Controller SSML")), ], 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") diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 51f35ff..9c6064e 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -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 = '' for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]): xml += f'' @@ -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: diff --git a/tests/review_integration.py b/tests/review_integration.py index e53ce49..905868c 100644 --- a/tests/review_integration.py +++ b/tests/review_integration.py @@ -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')