From 25e38907d3c43b313da6163e58323b736fa3f38d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 10 Oct 2026 02:55:42 -0400 Subject: [PATCH] Read Windows Forms login fields and associate their labels --- CMakeLists.txt | 2 +- README.md | 6 ++++- control_class.h | 17 ++++++++++++ edit_hook.c | 4 ++- list_view.cpp | 3 ++- main.cpp | 57 ++++++++++++++++++++++++++++++--------- tests/README.md | 31 +++++++++++++++++++++ tests/edit_fixture.c | 36 ++++++++++++++++++++++--- tests/menu_integration.py | 18 +++++++++---- 9 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 control_class.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 10ab6bc..4af5004 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,7 +16,7 @@ add_custom_command(OUTPUT "${menu_hook}" COMMAND "${MINGW_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared -static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32 - DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h + DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h control_class.h VERBATIM) add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}") diff --git a/README.md b/README.md index 90dc853..91979ae 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,11 @@ close. The hook applies to the helper's Wine desktop and prefix and is removed on normal shutdown. It is disabled by `--no-dialog-reader`. The same DLL subclasses standard `Edit` controls, including Wine Notepad's -document area. It reports the current line on focus and menu return, characters +document area and `WindowsForms10.EDIT.app.*` controls. Windows Forms fields +use visible sibling labels immediately to their left, even when the labels +were created after the fields. Protected fields report their label and +password role without forwarding their contents. +It reports the current line on focus and menu return, characters and words on horizontal navigation, lines on vertical/page/document-boundary navigation, changed selections, and Backspace/Delete/Cut feedback. Password contents are excluded, and read-only edits do not report unsuccessful deletions. diff --git a/control_class.h b/control_class.h new file mode 100644 index 0000000..b4ad31c --- /dev/null +++ b/control_class.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include + +// Windows Forms registers native-control subclasses with versioned names. +// Match the control token and its boundary; unrelated managed windows are +// not edits merely because their name contains EDIT somewhere. +static inline BOOL wine_access_class_matches(const WCHAR *className, const WCHAR *nativeName) { + if (lstrcmpiW(className, nativeName) == 0) return TRUE; + WCHAR expected[128] = L"WindowsForms10."; + if (lstrlenW(nativeName) > 80) return FALSE; + lstrcatW(expected, nativeName); + lstrcatW(expected, L".app."); + int length = lstrlenW(expected); + return lstrlenW(className) > length && + CompareStringOrdinal(className, length, expected, length, TRUE) == CSTR_EQUAL; +} diff --git a/edit_hook.c b/edit_hook.c index 318e4f9..e9b2fdf 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "edit_hook.h" #include "menu_bridge.h" +#include "control_class.h" #include #include @@ -186,7 +187,8 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l BOOL wine_access_watch_edit(HWND window) { WCHAR class_name[64]; - if (!window || !GetClassNameW(window, class_name, 64) || lstrcmpW(class_name, L"Edit")) return FALSE; + if (!window || !GetClassNameW(window, class_name, 64) || + !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; diff --git a/list_view.cpp b/list_view.cpp index 50bc4ab..e725a97 100644 --- a/list_view.cpp +++ b/list_view.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "list_view.h" +#include "control_class.h" #include #include #include @@ -38,7 +39,7 @@ constexpr size_t bufferSize = textOffset + textCapacity * sizeof(wchar_t); bool is_list_view(HWND window) { wchar_t className[64] = {}; return GetClassNameW(window, className, std::size(className)) && - lstrcmpiW(className, L"SysListView32") == 0; + wine_access_class_matches(className, L"SysListView32"); } int list_view_index(HWND window, int *count) { diff --git a/main.cpp b/main.cpp index 3a88663..af0e9b6 100644 --- a/main.cpp +++ b/main.cpp @@ -5,6 +5,7 @@ #include "edit_hook.h" #include "msaa_states.h" #include "list_view.h" +#include "control_class.h" #include #include @@ -162,12 +163,41 @@ void find_control_label(HWND window, wchar_t *text, int capacity) { HWND previous = window; wchar_t className[64] = {}; text[0] = L'\0'; + // Managed controls need not be created in label/control order. Prefer a + // visible static label immediately to the left on the same row, among + // siblings only. Keep the existing native-control ordering below. + if (GetClassNameW(window, className, std::size(className)) && + lstrcmpiW(className, L"Edit") != 0 && wine_access_class_matches(className, L"Edit")) { + RECT controlRect{}; + if (GetWindowRect(window, &controlRect)) { + HWND label = nullptr; + LONG bestGap = 201; + HWND sibling = GetWindow(GetParent(window), GW_CHILD); + for (int hop = 0; sibling && hop < 128; ++hop, sibling = GetWindow(sibling, GW_HWNDNEXT)) { + RECT labelRect{}; + if (sibling == window || !IsWindowVisible(sibling) || + !GetClassNameW(sibling, className, std::size(className)) || + !wine_access_class_matches(className, L"Static") || !GetWindowRect(sibling, &labelRect)) + continue; + const LONG gap = controlRect.left - labelRect.right; + if (gap >= 0 && gap < bestGap && labelRect.top < controlRect.bottom && + labelRect.bottom > controlRect.top) { + label = sibling; + bestGap = gap; + } + } + if (label && get_control_text(label, text, capacity)) { + tidy_text(text, true); + } + } + return; + } for (int hop = 0; hop < 3; ++hop) { previous = GetWindow(previous, GW_HWNDPREV); if (previous == nullptr || !GetClassNameW(previous, className, std::size(className))) return; - if (lstrcmpW(className, L"Static") != 0 && lstrcmpW(className, L"Button") != 0) + if (!wine_access_class_matches(className, L"Static") && !wine_access_class_matches(className, L"Button")) continue; if (get_control_text(previous, text, capacity)) tidy_text(text, true); @@ -211,14 +241,14 @@ ControlDescription describe_standard_control(HWND window) { if (!GetClassNameW(window, className, std::size(className))) return control; const LONG_PTR windowStyle = GetWindowLongPtrW(window, GWL_STYLE); - const bool isEdit = lstrcmpW(className, L"Edit") == 0 || - lstrcmpW(className, L"RICHEDIT50W") == 0 || - lstrcmpW(className, L"RichEdit20W") == 0 || - lstrcmpW(className, L"RichEdit20A") == 0; + const bool isEdit = wine_access_class_matches(className, L"Edit") || + wine_access_class_matches(className, L"RICHEDIT50W") || + wine_access_class_matches(className, L"RichEdit20W") || + wine_access_class_matches(className, L"RichEdit20A"); if (!isEdit || !(windowStyle & ES_PASSWORD)) get_control_text(window, text, std::size(text)); - if (lstrcmpW(className, L"Button") == 0) { + if (wine_access_class_matches(className, L"Button")) { control.name = to_utf8(text); const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); DWORD_PTR checked = 0; @@ -262,8 +292,8 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (lstrcmpW(className, L"ComboBox") == 0 || - lstrcmpW(className, L"ComboBoxEx32") == 0) { + if (wine_access_class_matches(className, L"ComboBox") || + wine_access_class_matches(className, L"ComboBoxEx32")) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "combo box"; @@ -287,8 +317,8 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (lstrcmpW(className, L"ListBox") == 0 || - lstrcmpW(className, L"ComboLBox") == 0) { + if (wine_access_class_matches(className, L"ListBox") || + wine_access_class_matches(className, L"ComboLBox")) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "list"; @@ -320,7 +350,7 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (lstrcmpW(className, L"msctls_trackbar32") == 0) { + if (wine_access_class_matches(className, L"msctls_trackbar32")) { find_control_label(window, label, std::size(label)); control.name = to_utf8(label); control.role = "slider"; @@ -331,7 +361,7 @@ ControlDescription describe_standard_control(HWND window) { return control; } - if (lstrcmpW(className, L"Static") == 0) { + if (wine_access_class_matches(className, L"Static")) { control.name = to_utf8(text); control.role = "static text"; return control; @@ -437,6 +467,9 @@ void present_text_event(const WineAccessTextEvent &event) { std::string text = to_utf8(event.text); ControlDescription control; if (event.kind == WINE_ACCESS_TEXT_PASSWORD) { + wchar_t label[256] = {}; + find_control_label(window, label, std::size(label)); + control.name = to_utf8(label); control.role = "edit"; control.description = "password"; } else if (event.kind == WINE_ACCESS_TEXT_FOCUS) { diff --git a/tests/README.md b/tests/README.md index deb3bea..0a29fba 100644 --- a/tests/README.md +++ b/tests/README.md @@ -99,6 +99,37 @@ Check arrows and Tab/Shift+Tab back to Install/Cancel. Also check a multirow native list, normal menus, editor navigation, switching to a native Linux application, and reader shutdown. Verify speech and physical braille separately. +## Windows Forms login fields + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture winforms +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture winforms --late-helper +``` + +This reuses the editing workflow with native controls registered under +Windows Forms class names and labels created after the fields. It verifies +`Email, edit` focus, `Password, edit, password` with no protected value, +navigation/selection/deletion and unrelated-label exclusion. It does not run +the .NET framework itself or establish live BARD speech/braille acceptance. +The ordinary and late-helper managed fixtures passed on October 10, together +with the existing native editor, Notepad, menu and 32/64-bit list regressions. + +Read-only inspection of the BARD login on October 10 confirmed a 64-bit +Windows Forms application with `WindowsForms10.EDIT.app.*` email/password +controls, adjacent visible `Email:`/`Password:` labels, and the `ES_PASSWORD` +style on the password field. Its MSAA objects were unavailable to the probe; +the previous exact `Edit` class checks missed both fields. The helper now +recognizes the managed native-control class names in its standard reader and +editor hook. Existing native controls keep their original label lookup. + +After installing the updated package, close BARD and restart Cthulhu so its +helper and the application load the new DLL together. Reopen BARD in its own +prefix. Tab and Shift+Tab between Email, Password, Sign in and Work Offline. +Expect labels and edit/password roles; navigate and select ordinary email +text, and confirm password contents stay excluded. Recheck native Notepad, +menus, the installer list and switching to Linux applications. Typing echo +and routable Wine review remain separate unfinished features. + ## Live Notepad acceptance Install all three helper files together and use the matching Cthulhu-Go source. diff --git a/tests/edit_fixture.c b/tests/edit_fixture.c index 0047845..f77dc23 100644 --- a/tests/edit_fixture.c +++ b/tests/edit_fixture.c @@ -1,6 +1,21 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include +#ifdef WINE_ACCESS_WINFORMS_FIXTURE +static const WCHAR *editClass = L"WindowsForms10.EDIT.app.0.fixture_r7_ad1"; +static const WCHAR *staticClass = L"WindowsForms10.STATIC.app.0.fixture_r7_ad1"; +static void register_control(HINSTANCE instance, const WCHAR *nativeName, const WCHAR *managedName) { + WNDCLASSEXW cls = {0}; + cls.cbSize = sizeof(cls); + if (!GetClassInfoExW(NULL, nativeName, &cls)) ExitProcess(3); + cls.hInstance = instance; + cls.lpszClassName = managedName; + if (!RegisterClassExW(&cls)) ExitProcess(4); +} +#else +static const WCHAR *editClass = L"Edit"; +#endif + static HWND editor, password; static int step; @@ -115,17 +130,30 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { (void)previous; (void)args; (void)show; +#ifdef WINE_ACCESS_WINFORMS_FIXTURE + register_control(instance, L"Edit", editClass); + register_control(instance, L"Static", staticClass); +#endif WNDCLASSW cls = {0}; cls.hInstance = instance; cls.lpszClassName = L"WineAccessEditFixture"; cls.lpfnWndProc = window_proc; RegisterClassW(&cls); HWND window = CreateWindowW(cls.lpszClassName, L"Wine editor fixture", WS_OVERLAPPEDWINDOW, 120, 120, 500, 300, NULL, NULL, instance, NULL); - editor = CreateWindowW(L"Edit", L"alpha beta\r\nsecond line", + editor = CreateWindowW(editClass, L"alpha beta\r\nsecond line", WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL, - 10, 10, 450, 150, window, NULL, instance, NULL); - password = CreateWindowW(L"Edit", L"private-password-fixture", - WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30, + 100, 10, 350, 150, window, NULL, instance, NULL); + password = CreateWindowW(editClass, L"private-password-fixture", + WS_CHILD | WS_VISIBLE | ES_PASSWORD, 100, 180, 300, 30, window, NULL, instance, NULL); +#ifdef WINE_ACCESS_WINFORMS_FIXTURE + // Labels are created after the fields, like the observed BARD login. + CreateWindowW(staticClass, L"Password:", WS_CHILD | WS_VISIBLE, 10, 183, 85, 20, + window, NULL, instance, NULL); + CreateWindowW(staticClass, L"Email:", WS_CHILD | WS_VISIBLE, 10, 13, 60, 20, + window, NULL, instance, NULL); + CreateWindowW(staticClass, L"Unrelated:", WS_CHILD | WS_VISIBLE, 10, 220, 80, 20, + window, NULL, instance, NULL); +#endif HMENU menu = CreateMenu(); AppendMenuW(menu, MF_STRING, 1, L"Test menu"); SetMenu(window, menu); diff --git a/tests/menu_integration.py b/tests/menu_integration.py index a163030..cf5a047 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -16,15 +16,15 @@ from gi.repository import Gio, GLib parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") -parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list"), default="menu") +parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "winforms"), default="menu") parser.add_argument("--32-bit", dest="is32Bit", action="store_true") parser.add_argument("--late-helper", action="store_true") parser.add_argument("--restart-helper", action="store_true") args = parser.parse_args() if args.is32Bit and args.fixture != "list": parser.error("32-bit checks currently require --fixture list") -if (args.late_helper or args.restart_helper) and args.fixture != "edit": - parser.error("helper lifecycle checks require --fixture edit") +if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms"): + parser.error("helper lifecycle checks require --fixture edit or --fixture winforms") build = Path(args.build_dir).resolve() if not args.session: sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, @@ -61,8 +61,10 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\") fixture = Path(temporary) / "menu-fixture.exe" compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc" + sourceName = "edit" if args.fixture == "winforms" else args.fixture subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", - "-o", str(fixture), str(Path(__file__).with_name(args.fixture + "_fixture.c")), + *(["-DWINE_ACCESS_WINFORMS_FIXTURE"] if args.fixture == "winforms" else []), + "-o", str(fixture), str(Path(__file__).with_name(sourceName + "_fixture.c")), "-luser32", "-lcomctl32"], check=True) helper = None app = None @@ -142,7 +144,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: assert any(e[3] == "edit" and e[4] == "otepad second line" for e in text_events), "Notepad menu return missing" print("PASS: actual Wine Notepad keyboard characters, words, lines, selection, deletion, and menu return") sys.exit(0) - if args.fixture == "edit": + if args.fixture in ("edit", "winforms"): text_events = [e for e in events if e[0].endswith(":text")] for event in text_events: print(event[2:7]) @@ -165,6 +167,12 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: assert sum(e[2] == "new line deleted" and "kind=4" in e[5] for e in text_events) == 1, "CRLF Delete feedback missing" assert any(e[4] == "x" * 1022 for e in text_events), "truncated surrogate discarded line" assert "d" in spoken and "undo restored" in spoken, "Undo left stale text snapshot" + if args.fixture == "winforms": + assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta" + for e in text_events), "managed email label/focus missing" + assert any(e[2] == "Password" and e[3] == "edit" and e[6] == "password" + and e[4] == "" for e in text_events), "managed password label/protection missing" + assert not any(e[2] == "Unrelated" for e in text_events), "unrelated label associated with field" print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls") sys.exit(0) menus = [event for event in events if ":menu:" in event[0]]