Read Windows Forms login fields and associate their labels

This commit is contained in:
Storm Dragon committed 2026-10-10 02:55:42 -04:00
1 parent d7ea005a1f
commit 25e38907d3
9 files changed
+149 -25

No files matched your search

+1 -1
View File
@@ -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}")
+5 -1
View File
@@ -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.
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
// 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;
}
+3 -1
View File
@@ -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 <commctrl.h>
#include <stdlib.h>
@@ -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;
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "list_view.h"
#include "control_class.h"
#include <commctrl.h>
#include <cstdint>
#include <cstddef>
@@ -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) {
+45 -12
View File
@@ -5,6 +5,7 @@
#include "edit_hook.h"
#include "msaa_states.h"
#include "list_view.h"
#include "control_class.h"
#include <commctrl.h>
#include <gio/gio.h>
@@ -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) {
+31
View File
@@ -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.
+32 -4
View File
@@ -1,6 +1,21 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h>
#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);
+13 -5
View File
@@ -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]]