Code optimization and bug fixes.

This commit is contained in:
ak47 committed 2026-10-14 20:55:28 -04:00
1 parent 4c47cc5fc4
commit b16c6968ae
11 files changed
+121 -25

No files matched your search

+2 -1
View File
@@ -149,7 +149,8 @@ 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
requires `PresentAccessibleEvent`, plus `SpeakText` and `CancelSpeech` for UIA
notifications even with `--no-controller`. 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.
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=wine-access-git
pkgver=r37.g417ee40
pkgver=r40.g4c47cc5
pkgrel=1
pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for screen readers'
arch=('x86_64')
@@ -32,7 +32,8 @@ build() {
-DCMAKE_CXX_FLAGS="$CXXFLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBEXECDIR=libexec
-DCMAKE_INSTALL_LIBEXECDIR=libexec \
-DWINE_ACCESS_INSTALL_DIR:PATH=libexec/wine-access
cmake --build "$srcdir/build"
}
+28 -10
View File
@@ -105,7 +105,12 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
BOOL selected = message == EM_SETSEL;
// A dispatched arrow belongs to ordinary caret navigation. Applications
// such as MUSHclient consume history arrows before DispatchMessage instead.
if (navigation || focus) state->history_key = state->history_replaced = FALSE;
// GetMessage can dispatch cross-thread sends before retrieving another
// queued message. Those replacements are not part of the UI thread's
// pretranslated history key, even when a history boundary left it pending.
BOOL remote_replacement = message == WM_SETTEXT && InSendMessageEx(NULL) != ISMEX_NOSEND;
if (navigation || focus || message == WM_KILLFOCUS || remote_replacement)
state->history_key = state->history_replaced = FALSE;
BOOL history_replacement = message == WM_SETTEXT && state->history_key;
if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR)
return DefSubclassProc(window, message, wp, lp);
@@ -243,7 +248,8 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
return result;
}
BOOL wine_access_watch_edit(HWND window) {
static BOOL watch_edit(HWND window, EditState **edit_state) {
if (edit_state) *edit_state = NULL;
WCHAR class_name[64];
if (!window || !GetClassNameW(window, class_name, 64)) return FALSE;
#ifdef _WIN64
@@ -253,8 +259,12 @@ BOOL wine_access_watch_edit(HWND window) {
if (!wine_access_class_matches(class_name, L"Edit")) return FALSE;
// Wine's comctl32 v5 exports GetWindowSubclass only by ordinal. Our own
// property also keeps attachment independent of the application's manifest.
if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE;
EditState *state = calloc(1, sizeof(*state));
EditState *state = (EditState *)GetPropW(window, EDIT_STATE_PROPERTY);
if (state) {
if (edit_state) *edit_state = state;
return FALSE;
}
state = calloc(1, sizeof(*state));
if (!state) return FALSE;
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
free(state); return FALSE;
@@ -268,20 +278,28 @@ BOOL wine_access_watch_edit(HWND window) {
HMODULE module;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
(LPCWSTR)(UINT_PTR)edit_proc, &module);
if (edit_state) *edit_state = state;
return TRUE;
}
BOOL wine_access_watch_edit(HWND window) {
return watch_edit(window, NULL);
}
__declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) {
if (code >= 0 && wp == PM_REMOVE) {
wine_access_watch_notifications();
HWND focus = GetFocus();
if (wine_access_watch_edit(focus))
EditState *state;
if (watch_edit(focus, &state)) {
SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
EditState *state = (EditState *)GetPropW(focus, EDIT_STATE_PROPERTY);
WCHAR class_name[64];
// Scintilla uses the same attachment property with a different state.
if (state && GetClassNameW(focus, class_name, 64) &&
wine_access_class_matches(class_name, L"Edit") && !state->busy) {
// The synchronous callback can destroy the control. Revalidate only
// on initial attachment, not for every message of an existing editor.
watch_edit(focus, &state);
}
// watch_edit returns typed state only for Edit, never for Scintilla's
// different structure stored under the same attachment property.
if (state && !state->busy) {
const MSG *message = (const MSG *)lp;
// Restrict replacement feedback to this retrieved input message.
// Timers, unrelated programmatic changes and background edits stay silent.
+3 -2
View File
@@ -176,8 +176,9 @@ bool reader_supports_protocol(bool controllerEnabled, bool dialogReaderEnabled)
bool required;
};
const RequiredMethod methods[] = {
{"SpeakText", "sb", "b", controllerEnabled},
{"CancelSpeech", "", "b", controllerEnabled},
// UIA notifications use speech directly even without the RPC controller.
{"SpeakText", "sb", "b", controllerEnabled || dialogReaderEnabled},
{"CancelSpeech", "", "b", controllerEnabled || dialogReaderEnabled},
{"BrailleMessage", "s", "b", controllerEnabled},
{"SpeakSsml", "ss", "i", controllerEnabled},
{"PresentAccessibleEvent", "sssssssiis", "b", dialogReaderEnabled}};
+5
View File
@@ -104,6 +104,8 @@ 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.
UIA speech and cancellation methods are checked even with the RPC controller
disabled; missing methods and incompatible input/output signatures must fail startup.
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
@@ -308,6 +310,9 @@ input rejected at the text limit. Command-history checks consume queued Up/Down
before dispatch, then replace the text and move the caret as MUSHclient does.
They require ordered, single announcements for equal-length and rapid recalls
and empty input, with no password, background or unrelated programmatic output.
An unchanged history boundary is followed by a worker-thread text replacement
before another queued message is removed. That replacement must stay silent,
and subsequent navigation must read from the updated text snapshot.
Both 32-bit and 64-bit fixtures use the same assertions. Lifecycle variants
attach after the control has focus or restart the helper without closing it.
Every text action must have an established
+27 -3
View File
@@ -29,12 +29,18 @@ objectPath = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_
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 + '">'
for method, (inputs, output) in methods.items():
def interface_xml(methods):
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>'
return xml + '</interface></node>'
xml = interface_xml(methods)
events = []
@@ -125,6 +131,24 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
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)
# UIA still uses direct speech when the NVDA RPC controller is disabled.
dialogMethods = {"SpeakText": ("sb", "b"), "CancelSpeech": ("", "b"),
"PresentAccessibleEvent": ("sssssssiis", "b")}
dialogCommand = ["--no-controller" if part == "--no-dialog-reader" else part
for part in helperCommand]
for method in ("SpeakText", "CancelSpeech"):
for signature in (None, ("s", "b"), (dialogMethods[method][0], "i")):
brokenMethods = dialogMethods.copy()
if signature is None:
del brokenMethods[method]
else:
brokenMethods[method] = signature
broken = connection.register_object(objectPath,
Gio.DBusNodeInfo.new_for_xml(interface_xml(brokenMethods)).interfaces[0],
record, None, None)
assert "reader method " + method in reject(dialogCommand)
connection.unregister_object(broken)
print("PASS: UIA speech methods required without the controller")
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"])
+32 -2
View File
@@ -22,6 +22,29 @@ static HWND editor, password, history;
static int step;
static int history_position;
static DWORD WINAPI update_history_from_worker(void *window) {
SendMessageW((HWND)window, WM_SETTEXT, 0, (LPARAM)L"unrelated worker update");
return 0;
}
static void unrelated_history_update(void) {
HANDLE thread = CreateThread(NULL, 0, update_history_from_worker, history, 0, NULL);
if (!thread) ExitProcess(2);
// Dispatch sent messages without removing a queued message, as GetMessage
// does while waiting for input. Do not rely on worker/timer scheduling.
for (;;) {
DWORD result = MsgWaitForMultipleObjects(1, &thread, FALSE, 2000, QS_SENDMESSAGE);
if (result == WAIT_OBJECT_0) break;
if (result != WAIT_OBJECT_0 + 1) ExitProcess(2);
MSG message;
PeekMessageW(&message, NULL, 0, 0, PM_NOREMOVE);
}
CloseHandle(thread);
WCHAR text[64];
GetWindowTextW(history, text, 64);
if (lstrcmpW(text, L"unrelated worker update")) ExitProcess(2);
}
// Model MUSHclient's PreTranslateMessage: consume queued arrows before the
// Edit procedure, then use SetWindowText and SetSel to recall a command.
static BOOL history_input(const MSG *message) {
@@ -29,7 +52,10 @@ static BOOL history_input(const MSG *message) {
(message->wParam != VK_UP && message->wParam != VK_DOWN)) return FALSE;
if (message->wParam == VK_UP && history_position < 2) ++history_position;
else if (message->wParam == VK_DOWN && history_position > 0) --history_position;
else return TRUE;
else {
if (step == 49) unrelated_history_update();
return TRUE;
}
const WCHAR *commands[] = {L"", L"history north", L"history south"};
SetWindowTextW(history, commands[history_position]);
int length = GetWindowTextLengthW(history);
@@ -239,10 +265,14 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); // no history change
break;
case 50:
// The silent worker update must still refresh the navigation snapshot.
PostMessageW(history, WM_KEYDOWN, VK_RIGHT, 1);
break;
case 51:
SendMessageW(history, EM_SETPASSWORDCHAR, L'*', 0);
PostMessageW(history, WM_KEYDOWN, VK_UP, 1);
break;
case 51:
case 52:
SetFocus(password);
SendMessageW(history, EM_SETPASSWORDCHAR, 0, 0);
PostMessageW(history, WM_KEYDOWN, VK_UP, 1); // background recall
+6 -1
View File
@@ -35,7 +35,12 @@ interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux
objectPath = "/" + interface.replace(".", "/")
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
xml += '<arg type="b" direction="out"/></method></interface></node>'
xml += '<arg type="b" direction="out"/></method>'
for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")):
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in inputs)
xml += '<arg type="b" direction="out"/></method>'
xml += '</interface></node>'
events = []
+7 -2
View File
@@ -56,7 +56,12 @@ if args.extension_endpoint:
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"/>'
xml += '<arg type="b" direction="out"/></method></interface></node>'
xml += '<arg type="b" direction="out"/></method>'
for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")):
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in inputs)
xml += '<arg type="b" direction="out"/></method>'
xml += '</interface></node>'
events = []
def record(connection, sender, path, iface, method, parameters, invocation):
@@ -317,7 +322,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
history_actions = [e[2] for e in text_events
if e[0] == history_focus and e[1] == "text"]
assert history_actions == ["history north", "history south", "history north", "blank",
"history north", "history south", "history north", "h", "blank", "blank"], \
"history north", "history south", "history north", "h", "blank", "blank", "n"], \
f"command history missing, reordered, duplicated or leaked: {history_actions!r}"
if args.fixture == "winforms":
assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta"
+6 -1
View File
@@ -31,7 +31,12 @@ interface = "org.stormux.Cthulhu1.Service"
object_path = "/org/stormux/Cthulhu1/Service"
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
xml += '<arg type="b" direction="out"/></method></interface></node>'
xml += '<arg type="b" direction="out"/></method>'
for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")):
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in inputs)
xml += '<arg type="b" direction="out"/></method>'
xml += '</interface></node>'
events, held = [], []
+2 -1
View File
@@ -32,7 +32,8 @@ capture_iface = 'org.stormux.Cthulhu1.WineReview'
capture_path = '/org/stormux/Cthulhu1/WineReview'
def interface_xml(review=True):
methods = {'PresentAccessibleEvent': ['s']*7+['i','i','s']}
methods = {'PresentAccessibleEvent': ['s']*7+['i','i','s'],
'SpeakText': ['s','b'], 'CancelSpeech': []}
if review:
methods.update(RegisterWineReviewProvider=['o','u'], PresentWineReviewFocus=['s','s','s','t','s','s','b','(iiii)'])
return '<node><interface name="'+iface+'">'+''.join(