diff --git a/CMakeLists.txt b/CMakeLists.txt index 31736e5..0482a74 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,7 @@ set(WINE_ACCESS_INSTALL_DIR "${CMAKE_INSTALL_LIBEXECDIR}/cthulhu/wine" CACHE PAT "Installation directory for the Wine helper and its Winelib sidecar") find_package(PkgConfig REQUIRED) pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) +pkg_check_modules(XCB REQUIRED IMPORTED_TARGET xcb) find_program(WIDL_EXECUTABLE widl REQUIRED) find_program(MINGW_C_COMPILER x86_64-w64-mingw32-gcc REQUIRED) find_program(MINGW32_C_COMPILER i686-w64-mingw32-gcc REQUIRED) @@ -26,12 +27,13 @@ set(menu_hook32 "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu32.dll") set(menu_host32 "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu-host32.exe") add_custom_command(OUTPUT "${menu_hook32}" "${menu_host32}" COMMAND "${MINGW32_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared - -static-libgcc -Wl,--kill-at -DWINE_ACCESS_MENU_ONLY -o "${menu_hook32}" - "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" -luser32 + -static-libgcc -Wl,--kill-at -o "${menu_hook32}" + "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" + "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32 COMMAND "${MINGW32_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -mwindows -static-libgcc -o "${menu_host32}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_host32.c" -luser32 - DEPENDS menu_hook.c menu_bridge.h menu_host32.c + DEPENDS menu_hook.c menu_bridge.h menu_host32.c edit_hook.c edit_hook.h control_class.h VERBATIM) add_custom_target(wine-access-menu32 ALL DEPENDS "${menu_hook32}" "${menu_host32}") @@ -47,12 +49,12 @@ add_custom_command( DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl" VERBATIM) -add_executable(cthulhu-wine-access main.cpp list_view.cpp native_items.cpp "${generated_server}") +add_executable(cthulhu-wine-access main.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}") add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32) target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20) target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}") target_link_libraries(cthulhu-wine-access PRIVATE - PkgConfig::GIO oleacc ole32 oleaut32 rpcrt4 user32) + PkgConfig::GIO PkgConfig::XCB oleacc ole32 oleaut32 rpcrt4 user32 gdi32) target_link_options(cthulhu-wine-access PRIVATE -mwindows) target_compile_options(cthulhu-wine-access PRIVATE $<$:-Wall -Wextra>) @@ -65,6 +67,31 @@ if(BUILD_TESTING) add_test(NAME msaa-states COMMAND "${CMAKE_CURRENT_BINARY_DIR}/msaa-states-test.exe") set_tests_properties(msaa-states PROPERTIES ENVIRONMENT "WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/test-prefix;CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=") + add_executable(review-capture-test tests/review_capture_test.cpp) + target_compile_features(review-capture-test PRIVATE cxx_std_20) + target_compile_options(review-capture-test PRIVATE -Wall -Wextra) + target_link_libraries(review-capture-test PRIVATE ole32 oleacc oleaut32 user32 gdi32 PkgConfig::GIO PkgConfig::XCB) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/review_capture.cpp") + target_sources(review-capture-test PRIVATE review_capture.cpp review_x11.cpp list_view.cpp native_items.cpp) + endif() + foreach(arch IN ITEMS 64 32) + if(arch EQUAL 32) + set(fixture_compiler "${MINGW32_C_COMPILER}") + else() + set(fixture_compiler "${MINGW_C_COMPILER}") + endif() + set(review_fixture "${CMAKE_CURRENT_BINARY_DIR}/review-fixture${arch}.exe") + add_custom_command(OUTPUT "${review_fixture}" + COMMAND "${fixture_compiler}" -std=c11 -Wall -Wextra -mwindows + -o "${review_fixture}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/review_fixture.c" -luser32 -lcomctl32 + DEPENDS tests/review_fixture.c VERBATIM) + add_custom_target(review-fixture${arch} ALL DEPENDS "${review_fixture}") + add_dependencies(review-capture-test review-fixture${arch}) + add_test(NAME review-capture${arch} + COMMAND "${CMAKE_CURRENT_BINARY_DIR}/review-capture-test.exe" --fixture "${review_fixture}") + set_tests_properties(review-capture${arch} PROPERTIES TIMEOUT 35 ENVIRONMENT + "WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/review-prefix${arch};CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=") + endforeach() endif() # Winegcc emits a launcher and a Winelib sidecar. CMake's normal target diff --git a/README.md b/README.md index cf40a70..c82e5cd 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,13 @@ Building requires an x86_64 Linux host with: - CMake 3.20 or newer; - Make and native GCC/G++ (the script uses CMake's default Makefile generator); - `pkg-config` and the GLib/GIO development files; +- XCB development files for Wine-associated X11 window geometry; - Wine development tools: `winegcc`, `wineg++`, and `widl`; - `x86_64-w64-mingw32-gcc` and `i686-w64-mingw32-gcc` to build native menu hooks (`mingw-w64-gcc` on Arch Linux). The D-Bus helper is a 64-bit Winelib executable. A small 32-bit Windows -companion supplies menu hooks for 32-bit applications, without requiring +companion supplies menu and standard Edit hooks for 32-bit applications, without requiring 32-bit Linux GLib/GIO development files. ## Build @@ -110,16 +111,17 @@ repeated selections, and restores standard-control focus reporting when menus close. The hooks apply to the helper's Wine desktop and prefix and are removed on normal shutdown. The 32-bit host attaches to the foreground 32-bit UI thread so Wine's global hook chain cannot interfere with 64-bit menus. It exits when the helper dies or its bridge -window closes. Menu hooks are disabled by `--no-dialog-reader`. The 32-bit -companion handles menus; the editor subclass described below remains 64-bit. +window closes. Menu and editor hooks are disabled by `--no-dialog-reader`. The +32-bit companion attaches both hooks to the same foreground UI thread. Focused combo boxes are checked every 100 ms to recover value changes that Wine does not report through WinEvents. The combo's internal list events use the same source identity as the focused combo, so the reader can accept the new value without a Tab/Shift+Tab round trip. -The 64-bit DLL subclasses standard `Edit` controls, including Wine Notepad's -document area and `WindowsForms10.EDIT.app.*` controls. Windows Forms fields +Both hook DLLs subclass standard `Edit` controls, including Wine Notepad's +document area, MUSHclient's command field and read-only output notepad, 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. @@ -130,7 +132,7 @@ contents are excluded, and read-only edits do not report unsuccessful deletions. Undo refreshes the text snapshot used by subsequent navigation. It can attach to an already-focused editor and resume after the helper restarts. -It also subclasses 64-bit `Scintilla` controls, including Notepad++'s document +The 64-bit DLL also subclasses `Scintilla` controls, including Notepad++'s document area. Up/Down and page navigation report the current document line; Left/Right report characters, and Ctrl+Left/Right report words. Focus and menu return read the caret's line. Ordinary selections, Backspace/Delete/Ctrl+X, @@ -163,8 +165,8 @@ so the helper queries the native control with target-architecture structures. When a populated list has no focused or selected row, focus reads its first row without changing selection. A bounded focus poll reports row navigation when Wine omits selection events. Up to eight columns and 1023 UTF-16 units -per column are read. This does not add 32-bit menu or editor hooks, column -navigation, or routable object review. +per column are read. These queries do not add column navigation or routable +object review. Native tab controls report their focused caption and position; tree controls report the selected node and its expansion state. A bounded focus poll follows @@ -179,25 +181,25 @@ Delphi VCL `TListView`, `TButton`, `TBitBtn`, and `TStaticText` subclasses use these same native queries. List views with standard checkboxes report checked/not-checked states and observe Space-key toggles even without a selection event. This covers GoldWave 5.70's Setup list and standard buttons; -arbitrary custom Delphi controls and 32-bit menu/editor hooks remain outside -this support. +arbitrary custom Delphi controls remain outside this support. Control values and window titles retain literal ampersands. Button and static label captions decode keyboard mnemonics and escaped `&&`; static labels with `SS_NOPREFIX` retain their literal text. -This is editing feedback, not a full text-review provider: Say All, review-cursor -commands and braille routing into Wine text are not implemented. Typing echo -remains the reader's responsibility. RichEdit and 32-bit editor hooks are not +These hooks provide editing feedback. Independent review-cursor commands use +the optional flat-review protocol described below; Say All and native braille +routing remain unsupported. Typing echo +remains the reader's responsibility. RichEdit and 32-bit Scintilla hooks are not covered. Reports are limited to 1023 UTF-16 units; documents over one million units use line feedback without full selection/deletion snapshots. Scintilla reads at most 4096 encoded bytes per report, then limits output to 1023 UTF-16 units without splitting surrogate pairs. Wrapped Scintilla lines report the underlying document line. -Keep all three installed files together. The native hook currently targets -64-bit applications; 32-bit menu coverage and custom menus that do not send -`WM_MENUSELECT` are not established. +Keep all five installed files together. Native menus and standard Edit controls +are supported in 32-bit and 64-bit applications; custom menus that do not send +`WM_MENUSELECT` are not covered. ## Menu integration check @@ -207,6 +209,7 @@ available (`python-gobject` on Arch Linux): ```sh python3 tests/menu_integration.py --build-dir build python3 tests/menu_integration.py --build-dir build --fixture edit +python3 tests/menu_integration.py --build-dir build --fixture edit --32-bit python3 tests/menu_integration.py --build-dir build --fixture notepad ``` @@ -226,6 +229,47 @@ application exits. The helper source is available under the terms of LGPL-2.1-or-later. See `LICENSE`. +## Independent Wine flat review + +With the matching Go reader, existing keypad review commands read visible +standard Edit text, button/static captions, native ListBox/ListView items, and +usable MSAA captions/values in both 32-bit and 64-bit applications. Each +command captures fresh text while preserving application focus, caret, +selection, scrolling and clipboard. Password and hidden contents are excluded +before text retrieval. Caption mnemonics are decoded; literal item ampersands +are preserved. Review text uses the reader's normal speech and braille queues. + +Custom painted game output still needs an accessible output provider, such as +MUSHclient's output plugin. RichEdit/Scintilla capture, semantic object review, +Say All, typing echo and native braille routing remain outside this protocol. + +Protocol version 1 adds `RegisterWineReviewProvider(ou) -> b` and +`PresentWineReviewFocus(ssstssb(iiii)) -> b` to the reader service. The helper +exports `/org/stormux/Cthulhu1/WineReview`, interface +`org.stormux.Cthulhu1.WineReview`, with `CaptureWindow(sst) -> a{sv}`. Only the +current same-user reader service owner can capture. Replies contain exactly +`version:u`, `instance:s`, `window:s`, `focus:s`, `revision:t`, `status:s`, +`bounds:(iiii)`, and `nodes:aa{sv}`. Nodes have `id`, `parent`, `role`, `name` +strings; `protected`, `visible`, `text_known`, `caret_known` booleans; +`caret`, `caret_utf16` unsigned 64-bit values; `bounds:(iiii)`; +and `ranges:a(tttts(iiii))` (rune start/end, UTF-16 start/end, text, bounds). +Bounds are screen x/y/width/height. Caption text offsets remain unknown. +Older readers retain ordinary focus/menu/editor feedback. + +Foreground bounds come from the X11 window associated with the HWND by +Wine's `__wine_x11_whole_window` property, using bounded XCB queries. This +handles window-manager decoration differences without title-based window +searches. Virtual-desktop child coordinates are translated to the screen; +the physical root geometry is checked again before returning text. + +Capture has a 500 ms total deadline, one active and one pending job, 4096 nodes, +64 hierarchy levels, 256 ranges per control, 4096 code points per displayed +line, 65536 per control, one million native source code units, 1 MiB of text +and 2 MiB of serialized data. Non-OK replies have no nodes; stale, busy, +limited and unavailable captures never reuse cached text. Capture runs on a +separate COM worker so blocked MSAA providers do not block menu/focus events. +See [tests/README.md](tests/README.md) for checks and live acceptance. + ## AI-assisted contributions AI-assisted contributions are welcome. The person submitting a contribution diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index 247cd48..f5db916 100644 --- a/distro-packages/ArchLinux/wine-access-git/PKGBUILD +++ b/distro-packages/ArchLinux/wine-access-git/PKGBUILD @@ -6,7 +6,7 @@ pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatib arch=('x86_64') url='https://git.stormux.org/storm/wine-access' license=('LGPL-2.1-or-later') -depends=('glib2' 'wine') +depends=('glib2' 'wine' 'libxcb') makedepends=('cmake' 'git' 'pkgconf' 'mingw-w64-gcc') provides=('wine-access' 'cthulhu-wine-access') conflicts=('wine-access' 'cthulhu-wine-access') diff --git a/docs/superpowers/plans/2026-10-11-wine-flat-review.md b/docs/superpowers/plans/2026-10-11-wine-flat-review.md new file mode 100644 index 0000000..52ac839 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-wine-flat-review.md @@ -0,0 +1,182 @@ +# Wine Flat Review Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. Native execution is recommended for these closely connected interfaces. + +**Goal:** Make Cthulhu's existing flat-review keys read exposed visible Wine text without moving the application's caret, selection, or focus. + +**Architecture:** wine-access supplies owned window snapshots through an optional D-Bus capture endpoint. A Go provider registry and capturer composition feed those snapshots to the existing review worker and flat cursor. Event-loop foreground ownership and provider lifetime checks reject stale results. + +**Tech Stack:** C++20 Winelib, Wine Unicode/Win32/MSAA APIs, existing GLib/GIO and XCB; Go 1.24+, existing godbus and AT-SPI snapshot/review code; Python/PyGObject and XTest private-display fixtures. Delivery testing required an explicit libxcb link/development dependency for actual Wine-associated X11 geometry; XCB was already installed on the tested system. + +**Spec:** [Approved Wine flat-review design](../specs/2026-10-11-wine-flat-review-design.md). + +## Global Constraints + +* Preserve existing uncommitted 32-bit Edit/menu changes and `cthulhu.log`. +* Keep desktop/laptop review bindings, speech/spelling/phonetics, braille queues, and silent movement boundaries. +* Never move native focus/caret/selection, scroll, copy to the clipboard, or inject application navigation keys to implement review. +* Password text is excluded before copying, serialization, presentation, and debug logging. +* Capture deadline: 500 ms total; one active native capture and one pending capture; command FIFO remains 16. +* Limits: 4096 nodes, depth 64, 256 ranges per control, 4096 code points per visual line, 65536 captured code points per control, 1 MiB text per window, 2 MiB serialized snapshot, one million UTF-16 units of full Edit source reads per window. +* Captions: 4096 UTF-8 bytes; identities: 256 UTF-8 bytes. Reject duplicate IDs, cycles, malformed UTF-8, offsets, bounds, and versions. +* Return complete snapshots or an explicit non-ok status; at most one stale recapture under the original deadline. +* Preserve `PresentAccessibleEvent`, NVDA/Tolk, optional-helper behavior, Linux review, and XTerm/Fenrir handoff. +* Core input/event loops never perform native capture or synchronous D-Bus capture calls. +* No native pointers/COM objects cross the bus. Keep each native object's release on its COM STA. +* Xorg correctness is required; private displays run sequentially. Distinguish recorded output, installed-binary checks, and live speech/braille acceptance. +* Exposed standard Edit/native/MSAA text is supported in both architectures. Custom painted text, RichEdit/Scintilla text providers, semantic object navigation, Say All, and native braille routing are outside this change. +* Use tool approval before writing reader source or installed files outside writable roots. Read applicable AGENTS.md before edits. Leave submission and human acceptance to the contributor. + +## Review Focus + +1. A blank command field must anchor review of other visible text; test in Tasks 1 and 6. +2. Same-length text replacements during capture must not produce mixed old/new text; test in Tasks 1 and 2. +3. Two applications with identical titles must not share review ownership; test in Tasks 4 and 6. +4. Negative monitor coordinates and Wine virtual-desktop child focus must match current foreground geometry; test in Tasks 1, 6, and 7. +5. A stalled MSAA call must leave menus, input, and shutdown responsive; test in Tasks 2 and 7. + +## File ownership and interface map + +Paths prefixed `reader/` mean `/home/deedra/git/cthulhu-go`; other paths belong to this standalone helper repository. These describe source responsibilities, not new packages for every file. + +* Helper `review_capture.h/.cpp`: owned capture records, offsets, visibility, native lifetime identity, and window/control capture. +* Helper `review_service.h/.cpp`: D-Bus serialization, authorization, registration, bounded STA capture worker, and cancellation/lifetime handling. +* Helper `main.cpp`: start/stop service, post focus/lifecycle notices; retain existing event/menu policy. +* Helper `tests/review_fixture.c`, `tests/review_integration.py`, `tests/review_capture_test.cpp`: native behavior and transport tests. +* Reader `internal/wine/review_protocol.go`, `review_snapshot.go`: wire types, limits, and conversion to owned trees. +* Reader `internal/wine/review_registry.go`, `review_client.go`: connection ownership, registered helper state, focus mailbox, capture calls, and provider disappearance. +* Reader `internal/remote/wine_review.go`: optional authorized registration/focus methods and introspection. +* Reader `internal/app/wine_review_capture.go`, `wine_review_focus.go`: capturer composition and foreground/focus/result policy respectively. +* Reader `internal/review/flat_worker.go`: optional fresh-capture policy; preserve the existing flat navigation algorithm. +* Reader runtime/event files: dependency wiring and event ordering only. + +## Task 1: Read-only native visible-window capture + +**Files:** Create `review_capture.h`, `review_capture.cpp`, `tests/review_fixture.c`, `tests/review_capture_test.cpp`; modify `CMakeLists.txt`. + +**Interfaces produced:** + +* `ReviewFocus { std::string instance, window, focus, title, role; uint64_t revision; bool protected_text; RECT bounds; }`. +* `ReviewRange { uint64_t start, end, utf16_start, utf16_end; std::string text; RECT bounds; }`. +* `ReviewNode { std::string id, parent, role, name; bool protected_text, visible, text_known, caret_known; uint64_t caret, caret_utf16; RECT bounds; std::vector ranges; }`. +* `ReviewCapture { ReviewFocus source; std::string status; std::vector nodes; }`. +* `class ReviewIdentityStore`: `ReviewFocus observe_foreground()`, `void destroyed(HWND, LONG object, LONG child)`, `void reordered(HWND, LONG object)`, `void retire()`; synchronize lifetime registry access between native event and capture threads. Revisions change on focus/lifetime changes, not text changes. +* `ReviewCapture capture_review_window(ReviewIdentityStore&, const ReviewFocus&, uint64_t deadline_tick)`; deadline uses `GetTickCount64()` and all source/size limits above. + +- [x] Write `tests/review_capture_test.cpp` and fixture cases named `visible_unicode`, `wrapped_and_blank`, `empty_anchor`, `password`, `clipped_hidden`, `same_length_update`, `native_controls`, `source_limit`, and `negative_coordinates`. Assert exact exposed text/rune offsets, unchanged focus/selection, and `limited` with zero nodes at limits. Include a surrogate before the first visible range so absolute rune offsets cannot be computed from visible text alone. +- [x] Add CTest entries `review-capture64` and `review-capture32`. Compile fixture binaries `build/review-fixture64.exe` and `build/review-fixture32.exe` with the existing MinGW compilers; `review-capture-test.exe --fixture ` launches and checks each fixture under its isolated prefix. Run `ctest --test-dir build -R review-capture --output-on-failure` on a private display before production capture exists; confirm the failure names the missing capability rather than fixture/setup failure. +- [x] Implement identities and capture. Query native Edit text/positions, visible line/wrap ranges and caret without edits; traverse bounded MSAA/native controls, omit protected contents, strip caption mnemonics only, and preserve empty focus as a node. Map each absolute UTF-16 position through the bounded source read. Verify source identity and reread collected ranges before returning, including same-length updates. +- [x] Build with `cmake --build build --parallel`; run the focused capture test on a private display with both fixture architectures. PASS means expected text and positions, no password token anywhere, and unchanged native state. Run `git diff --check` and review this task's diff. + +Exact offset assertion for an offscreen prefix `😀alpha\n` followed by visible `beta`: + +```cpp +assert(range.start == 7 && range.end == 11); +assert(range.utf16_start == 8 && range.utf16_end == 12); +assert(range.text == "beta"); +``` + +## Task 2: Optional bounded helper capture service + +**Files:** Create `review_service.h`, `review_service.cpp`, `tests/review_integration.py`; modify `main.cpp`, `CMakeLists.txt`, `tests/review_fixture.c`. + +**Consumes:** Task 1 records, identity store, and capture function. +**Produces:** `class ReviewService` with `bool start(GDBusConnection*, ReviewIdentityStore&)`, `void focus_changed(const ReviewFocus&)`, and `void stop()`; exact version-1 methods/dictionary in the spec. No extra installed artifact. + +- [x] Write private-session tests `test_capture_wire_contract`, `test_wrong_caller`, `test_old_reader`, `test_busy_capture`, `test_capture_deadline`, `test_same_length_capture_race`, and `test_stop_during_capture`. Assert an unavailable optional reader does not stop ordinary helper speech; stale/busy/limited replies have zero nodes; a blocked capture does not delay existing menu/focus events; shutdown remains bounded. +- [x] Run `python3 tests/review_integration.py --build-dir build --32-bit` on a private display and confirm missing endpoint/registration is the red failure. +- [x] Implement native records-to-`a{sv}` conversion and exact dictionary field types. Register `/org/stormux/Cthulhu1/WineReview` on the existing unique connection; authorize the current same-user Cthulhu service owner. Own a dedicated GLib main context/dispatcher thread because the current helper's Win32 message loop does not dispatch a GLib main context. Dispatch native work to a separate COM STA; the dispatcher must reject excess work even when capture blocks. Reply once per invocation, expire pending work within the original 500 ms, and release native references on their STA. No forced thread termination. +- [x] Wire start/stop and focus/destruction/reorder notices from the existing event loop. Poll recovers missed focus events without registering a new provider per notice. Retry registration when a compatible reader owner appears. Continue existing speech if methods are unavailable. +- [x] Run both `python3 tests/review_integration.py --build-dir build` and its `--32-bit` variant sequentially; PASS verifies typed schema, authorization, native state, bounded busy behavior, and older-reader compatibility. Re-run existing menu/editor fixtures because startup and shutdown changed. Review the diff. + +## Task 3: Validate snapshots and build Go review trees + +**Files:** Create `reader/internal/wine/review_protocol.go`, `review_snapshot.go`, `review_snapshot_test.go`. + +**Interfaces produced:** `ReviewFocusNotice` mirrors the spec focus fields and includes actual sender/path copied from registration; `ReviewSnapshot`/`ReviewNode`/`ReviewRange` mirror wire records with `uint64` offsets and `atspi.Rectangle` bounds. `DecodeReviewSnapshot(fields map[string]dbus.Variant) (ReviewSnapshot, error)` validates types/limits; `ApplyReviewSnapshot(tree *atspi.AccessibleTree, snapshot ReviewSnapshot, sender string) (*atspi.AccessibleTree, atspi.ObjectID, error)` creates or replaces a stable owned tree. `IsReviewID(atspi.ObjectID) bool` checks the exact reserved namespace. Define sentinel errors `ErrReviewUnavailable`, `ErrReviewStale`, `ErrReviewBusy`, `ErrReviewLimited`, and `ErrReviewInvalid` for status and validation handling. + +- [x] Write `TestReviewSnapshotUnicodeAndStableTree`, `TestReviewSnapshotEmptyAnchor`, `TestReviewSnapshotProtectedContent`, and table-driven `TestReviewSnapshotRejectsInvalidPayload`. Assert code-point counts, valid `/wine/` identities, unchanged tree pointer/live generations after text updates, new generations after removal/reuse, and rejection of every spec bound plus cycles, duplicates, malformed nested types, offset overflow, and clipped-range violations. +- [x] Run `GOCACHE=/tmp/cthulhu-go-cache go test ./internal/wine -run ReviewSnapshot -count=1` from the reader checkout; confirm tests fail for the missing protocol/conversion. +- [x] Implement exact dictionary decoding and conversion. Opaque IDs map to safe object-path components; root has window/dialog role; protected controls become `password text` with no ranges/value. Use existing tree validation/Replace rather than a parallel tree model. Never convert `uint64` to `int` before range checking. +- [x] Re-run the focused tests and `go test -race ./internal/wine -count=1`; PASS includes the existing manager tests. Review the diff. + +The invalid-payload table must assert rejection rather than acceptance of partial data: + +```go +snapshot, err := DecodeReviewSnapshot(fields) +if err == nil || len(snapshot.Nodes) != 0 { + t.Fatalf("invalid payload accepted: nodes=%d err=%v", len(snapshot.Nodes), err) +} +``` + +## Task 4: Provider registration, lifetime, and bounded Go capture + +**Files:** Create `reader/internal/wine/review_registry.go`, `review_client.go`, `review_registry_test.go`, `review_client_test.go`, `reader/internal/remote/wine_review.go`, `wine_review_test.go`; modify `reader/internal/remote/service.go`, `introspection.go`. + +**Consumes:** Task 3 wire records, errors, and tree conversion. +**Produces:** `NewReviewRegistry(logger *slog.Logger) *ReviewRegistry`; registry methods `Start(context.Context) error`, `Stop(context.Context) error`, `RegisterProvider(sender string, path dbus.ObjectPath, version uint32) bool`, `PresentFocus(sender string, notice ReviewFocusNotice) bool`, `Changes() <-chan struct{}`, `Focuses() []ReviewFocusNotice`, `Capture(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error)`, and `Current(atspi.ObjectID) bool`. `Changes` is a capacity-one notification; registry retains latest state per sender, not one lossy notice for all prefixes. Remote optional `WineReviewHandler` has the RegisterProvider/PresentFocus signatures; expose spec method names through authorized wrappers using the actual `dbus.Sender`. + +- [x] Write `TestReviewRegistryTwoProviders`, `TestReviewRegistrySenderAssociation`, `TestReviewRegistryProviderDisappears`, `TestReviewCaptureDeadlineAndStaleReply`, and real private-bus `TestNativeSessionBusWineReview`. Assert unique connections/instances do not collide even for matching HWND/title; owner disappearance removes focus/tree; re-registration retires the old instance; focus notices from an unregistered or unauthorized sender are rejected; capture returns by 500 ms and Stop cancels work. +- [x] Run `go test ./internal/wine ./internal/remote -run 'ReviewRegistry|ReviewCapture|WineReview' -count=1`; confirm the red result is the missing registry/endpoint behavior. +- [x] Implement same-user service wrappers and introspection without changing existing Handler implementations. Registry owns a separate session-bus connection for captures/disconnection monitoring, unregisters its match rules on Stop, and targets only registered unique senders. Apply one original deadline, one stale retry, and exact expected identity/revision checks to capture; cache stable trees per provider/window lifetime, never cached text as a substitute for a successful current capture. +- [x] Run unit/race checks, then `dbus-run-session -- env CTHULHU_NATIVE_DBUS_TEST=1 GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/wine ./internal/remote -run NativeSessionBusWineReview -count=1`. PASS proves actual wire registration, capture, and disappearance. Review the diff. + +## Task 5: Feed fresh Wine snapshots to the existing review worker + +**Files:** Create `reader/internal/app/wine_review_capture.go`, `wine_review_capture_test.go`; modify `reader/internal/review/worker_contract.go`, `flat_worker.go`, `flat_worker_test.go`, and `reader/internal/app/runtime_review.go`. + +**Consumes:** `ReviewRegistry.Capture` and `IsReviewID`. +**Produces:** `type review.FreshReviewWindowCapturer interface { FreshReviewWindow(atspi.ObjectID) bool }`. App `reviewCaptureRouter` composes the existing `review.TreeCapturer` and registry, implements its four capture methods plus `FreshReviewWindow`. Wine review uses registry capture; document capture rejects Wine IDs; native IDs delegate exactly as before. + +- [x] Write `TestFlatWorkerFreshProviderPreservesCursor`, `TestFlatWorkerFreshProviderFailureDoesNotSpeakCachedText`, `TestReviewCaptureRouterNativeDelegation`, and `TestReviewCaptureRouterWineIDsNeverReachATSPINativeCalls`. Read `alpha beta`, move independently, replace the same live control's text, then assert the next command sees new text at the preserved offset. A native-provider spy must see no Wine object paths. +- [x] Run `go test ./internal/review ./internal/app -run 'FreshProvider|ReviewCaptureRouter' -count=1`; confirm the missing fresh-provider policy is the failure. +- [x] In `readFlat`, detect the optional policy and capture once per Wine command before cursor refresh. Keep capture on the review worker, apply the 500 ms Wine timeout, preserve the same tree through Replace, and prohibit repeated capture retries beyond the one allowed under the original deadline. Leave the existing AT-SPI stale-capture path and navigation algorithm intact. +- [x] Map `wine.ErrReviewLimited` to `review.ErrLimitedReview` in the router so the established limit message is used. Inject the composed capturer through runtime review construction. Run focused tests and existing flat-worker/cursor tests with race checks; PASS includes all review units and queued spelling styles. Review the diff. + +## Task 6: Own Wine review focus and output in the event loop + +**Files:** Create `reader/internal/app/wine_review_focus.go`, `wine_review_focus_test.go`; modify `app.go`, `runtime_remote.go`, `live_settings.go`, `event_loop.go`, `event_review_results.go` and the focused lifecycle/focus tests only as needed. + +**Consumes:** registry focus snapshots/change notifications, `Current`, router from Task 5, existing foreground/title source and review worker. +**Produces:** `wineReviewFocusController` with `changed()`, `foregroundChanged()`, `retire()`, `owns(atspi.ObjectID) bool`, and `resultCurrent(review.Result) bool`. Event-loop-owned active state stores provider, window/focus lifetime, native source revision, foreground epoch, and root bounds. Registry connection/capture state stays outside the event loop. + +- [x] Write `TestWineReviewEmptyFocusCanReachOutput`, `TestWineReviewIdenticalTitlesDoNotClaimForeground`, `TestWineReviewForegroundAndVirtualDesktop`, `TestWineReviewDropsOldResultsAndRestarts`, and `TestWineReviewSpeechBrailleAndSpelling`. Assert matching geometry/epoch ownership, negative coordinates, virtual desktop child identity, duplicate notices preserving position, provider restart/lock invalidation, and restored Linux focus. Check exact existing line/word/character/spelling requests and password-free braille output. +- [x] Run `go test ./internal/app -run WineReview -count=1`; confirm missing Wine review ownership is the red failure. +- [x] Wire one registry instance to review construction and remote registration. Add a lifecycle entry whose shutdown cancels registry work before its connection closes; reuse the existing reader supervisor. Event-loop callbacks reconcile registry notices with current Xorg foreground geometry/title-child metadata and worker focus. Ambiguous/unknown ownership rejects Wine review; raw titles alone cannot establish it. +- [x] Before presenting flat results, synchronize current foreground and require matching provider/focus lifetime/revision/epoch. On a revision transition, call `ClearFocus` to retire the old worker document epoch before restoring any new focus, even when the same control identity returns; duplicate notices without a transition preserve position. Retire queued work on focus loss, lock, destruction, or provider death. Prevent native events for an earlier Linux window from overwriting current Wine review focus. Keep policy in the controller and event ordering in the loop. +- [x] Run app/review/remote/wine race checks and existing Linux flat-review, foreground, titles, password, spelling, and braille tests. PASS proves shared behavior and output queues; it is not a live speech claim. Review the diff. + +## Task 7: Prove physical key routing through both architectures + +**Files:** Create `reader/tests/wine_review_x11.py`, `reader/internal/app/wine_review_native_test.go`; extend helper `tests/review_fixture.c`/`review_integration.py` with deterministic handshake and stalled-capture modes. + +**Consumes:** installed-shaped helper build, real Go service/command router, registry and capture paths. Use the existing private X11 fixture's physical XTest keycode helper, not modifier-changing keysym synthesis. + +- [x] Write a private-session/display acceptance fixture that fails unless keypad line/word/character, spelling/phonetic presses, window start/end, line end and vertical-word review produce expected text. Assert fixture focus, caret, selection and document content remain unchanged; ordinary application arrow keys still work afterward. +- [x] Add focus-switch, negative-coordinate, virtual-desktop, two-prefix, reader/helper restart, and streaming-output phases. Stall capture deliberately and prove menu/focus speech still arrives, key release remains balanced, Linux review responds, cancellation joins within the normal shutdown budget, and no late Wine text is spoken after switching away. +- [x] Run the new fixture before final wiring is considered complete; investigate any failure at the helper/registry/focus/command/output boundary instead of weakening assertions. +- [x] Run both architectures sequentially, then the existing helper MSAA/menu/edit/list/control fixtures and focused reader native Xorg/Fenrir handoff checks. Record command, exit status, capture/input timing and final output evidence; PASS requires real X11 command consumption and correct queued speech/braille. Physical listening remains a separate user check. + +## Task 8: Review, document, build, and deliver matching binaries + +**Files:** Update helper `README.md`, `tests/README.md`; reader `docs/testing/wine-accessibility.md`, `MIGRATION_PLAN.md` only for scope/evidence/gaps. Source changes remain in their owning repositories. + +- [x] Review both complete diffs against the spec, including unrelated-file preservation and password exclusion. Obtain one independent whole-change code review for native execution, or the selected task reviews if subagent execution was chosen; resolve verified findings and rerun affected tests. +- [x] Run `git diff --check` in both repositories, build the helper and reader, and run only outstanding relevant checks after the last edits. Do not broaden testing again without a new failure/change/concern. +- [x] Update docs with exact protocol/support limits and actual command evidence. Clearly distinguish private fixtures, full-router/native checks, installed-binary verification and remaining MUSHclient listening/braille checks. Remove the now-resolved Wine flat-review gap while preserving excluded providers/routing gaps. +- [x] Prepare matching binaries and backups, then use tool approval to install outside writable roots. Replace mapped helper artifacts atomically. Reload the helper/reader through verified lifecycle paths while preserving MUSHclient and the current game connection. Verify the new processes stay alive and registration/capture uses the new protocol. +- [x] Provide concise live checks: current/previous/next line in output notepad, word/character/spelling, independent caret, new incoming output, plugin dialogs, and return to Linux/Fenrir. Keep changes reviewable and leave contribution submission to the user. + +## Execution prerequisites and handoff + +The user approved the spec, plan and native inline execution on 2026-10-11. +Tasks 1–8 are complete. Independent whole-change review and its verified fix +pass are complete. Matching binaries are installed and the actual desktop +check passed with the current MUSHclient process and game socket preserved. +The isolated execution ledger records exact checks, decisions and live gates. + +At execution start, apply the chosen execution skill and its isolation rules. +Inspect both repositories' working state; preserve a scoped baseline of the +existing helper changes, and obtain predictable tool escalation for repository +metadata or reader-source writes outside writable roots. Do not discard or +automatically submit the current work. Track task completion in this document. diff --git a/docs/superpowers/specs/2026-10-11-wine-flat-review-design.md b/docs/superpowers/specs/2026-10-11-wine-flat-review-design.md new file mode 100644 index 0000000..191fdd3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-11-wine-flat-review-design.md @@ -0,0 +1,233 @@ +# Cthulhu review keys in Wine + +Status: approved, implemented, verified and installed on 2026-10-11. Independent review fixes and matching-binary desktop checks completed; MUSHclient listening and physical braille acceptance remain live checks. + +## Intended outcome + +The user wants the existing Cthulhu review keys to work in Wine, especially +MUSHclient running in `~/.local/bard`. Keep the current desktop keyboard +bindings. Review must read and navigate independently of application focus, +caret, selection, and keyboard input. The user confirmed this scope on +2026-10-11. + +Support current/previous/next line, word, and character; current-word spelling +and phonetics; current-character phonetics; window start/end; word above/below; +and line end. Reuse current speech and braille presentation and boundary +behavior. This is flat text review, not semantic parent/child object navigation, +Say All, editing, mouse activation, or braille routing into Windows controls. + +Cover exposed visible text and controls in 32-bit and 64-bit Wine applications. +The concrete MUSHclient cases are its command Edit and read-only output notepad. +Its custom game display does not become readable merely by adding this bridge; +the existing output plugin remains the source of reviewable game text. + +## Existing behavior and cause + +`wine-access/main.cpp` supplies normalized focus/menu/editor events to +Cthulhu's session D-Bus service. The 32-bit standard Edit feedback change is +already implemented and locally installed. Preserve that work. + +`cthulhu-go/internal/app/runtime_commands.go` sends review commands to +`review.Worker`. Its `CaptureReviewWindow` provider currently captures only +AT-SPI objects. Wine focus events go to a separate speech presenter and never +establish a review provider or a text snapshot. A spoken caret line is not a +reviewable window. + +`review.FlatCursor` already navigates immutable window trees and preserves a +position through stable object identities. `AccessibleTree.Replace` preserves +generations of live identities. Reuse these mechanisms instead of implementing +a second Wine-specific navigation algorithm or reviewing cached speech. + +## Architecture + +### Wine capture provider + +Add a focused capture module beside `main.cpp`, rather than putting enumeration +and serialization in its event loop. The helper publishes a read-only D-Bus +capture endpoint on its existing unique session-bus connection. Multiple Wine +prefixes therefore have independent endpoints without competing for one name. + +Capture uses a dedicated COM STA worker, independent of the existing native +event/menu thread. Permit one active capture and one pending capture; reject +additional requests promptly. Native accessibility objects stay on that STA +and are released there. Application queries use `SendMessageTimeoutW` where +available. A COM call that does not return cannot be forcibly cancelled safely; +the Go deadline discards its result, and the busy provider rejects further +capture work instead of accumulating it. Helper shutdown remains bounded by +the supervisor's existing process-stop policy. + +Collect a complete snapshot of the foreground Wine window/dialog: hierarchy, +stable identities, roles, captions, protected states, clipped screen bounds, +visible text ranges, and the focused Edit's caret when available. Traverse MSAA +objects and use existing standard-control queries when Wine exposes only a +generic client. Standard Edit includes Windows Forms Edit subclasses, in both +architectures. Preserve mnemonic decoding for captions and literal ampersands +in values. Password text is excluded before copying or serializing it. + +For Edit text, collect displayed visual lines, including soft wraps and blank +rows, with native character positions and client clipping. Do not scroll, +select text, change focus, send navigation keys, or copy to the clipboard. +The helper converts UTF-16 offsets to Unicode code-point offsets before +serialization, retaining native offsets for diagnostics. Computing absolute +offsets requires the preceding source text, so bound full Edit source reads at +one million UTF-16 units in total per window and return `limited` above that +bound. Only displayed ranges cross D-Bus. No approximate character geometry +is presented as native. + +First implementation uses standard Edit and exposed native/MSAA captions and +values. Custom painted text, RichEdit, and Scintilla text capture are excluded +unless their existing accessibility data supplies usable visible text. Their +caret feedback remains as it is today. + +### Additive protocol + +Leave `PresentAccessibleEvent` and NVDA/Tolk contracts unchanged. Add: + +* `RegisterWineReviewProvider(object_path: o, protocol_version: u) -> b` to the Cthulhu + service. Version 1 registration is associated with the actual unique D-Bus + sender after the existing same-user authorization, never a caller-supplied + destination name. Registration supplies no native references. +* `PresentWineReviewFocus(instance: s, window: s, focus: s, revision: t, + title: s, role: s, protected: b, bounds: (iiii)) -> b` for that registered + sender. Window and focus are opaque lifetime identities. Emit on focus/owner + changes and retire identities + on destruction. The existing bounded foreground poll recovers missed events. +* `CaptureWindow(expected_window: s, expected_focus: s, expected_revision: t) + -> a{sv}` on + `/org/stormux/Cthulhu1/WineReview`, interface + `org.stormux.Cthulhu1.WineReview`. Return a versioned owned snapshot with its + identities, revision, bounds, nodes, text ranges, and capture status. + +The capture dictionary has required keys `version: u`, `instance: s`, +`window: s`, `focus: s`, `revision: t`, `status: s`, `bounds: (iiii)`, and +`nodes: aa{sv}`. Status is `ok`, `unavailable`, `stale`, `busy`, or `limited`; +non-ok responses contain no nodes. Each node has `id: s`, `parent: s` (empty +only at root), `role: s`, `name: s`, `protected: b`, `visible: b`, +`bounds: (iiii)`, `text_known: b`, `caret_known: b`, `caret: t`, +`caret_utf16: t`, and `ranges: a(tttts(iiii))`. Ranges contain code-point +start/end, native UTF-16 start/end, exact UTF-8 text, and bounds. `caret` +counts code points. Go validates offsets, rune counts, and the resulting +tree before use. Captions and identity fields +are individually bounded at 4096 and 256 UTF-8 bytes respectively. Limit +traversal depth to 64 and reject cycles or duplicate node identities. + +Revision tracks focus/window lifetime changes, not incoming text. Within a +capture, recheck foreground/focus identity before returning and verify that +each collected control's text length and visible ranges remain consistent. +Return `stale` on a detected capture race, with at most one recapture under +the original deadline. Text updates do not retire live control identities. + +Native handles in identities are opaque fixed-width integers. Include a +helper-instance identity and a lifetime counter so reused HWNDs cannot inherit +an old review position. MSAA child identities remain subordinate to their +owning window lifetime; destroy/reorder events retire uncertain child identity. +Do not serialize pointers, COM objects, or prefix environment data. + +The capture endpoint authorizes the current Cthulhu service owner under the +same-user trust model. Reject invalid versions, identities, dimensions, offsets, +UTF-8, and oversized messages at both ends. Older helpers/readers retain normal +speech; review reports unavailable when this optional protocol is absent. + +### Go ownership and integration + +Place provider registry, capture client, validation, and snapshot conversion +in cohesive `internal/wine` files. The registry owns its session-bus connection +and tracks provider disconnection. Copied focus notices go to the event-loop +owner through a bounded mailbox; D-Bus callbacks never mutate event-loop state. + +Compose a review capturer around the existing AT-SPI adapter. Wine identities +use the registered helper's unique bus name and a reserved `/wine/...` object +path namespace. Only this composition layer routes those identities to Wine; +they must never be sent to AT-SPI native operations. Ordinary AT-SPI capture +keeps its current path. + +The event loop owns which focus source is eligible. On Xorg, corroborate Wine +window bounds and metadata with the current foreground client and epoch. +Support a child window inside Wine's virtual desktop using the existing +foreground/title discovery. Title equality alone cannot establish ownership. +When foreground ownership is unknown, reject Wine capture rather than using +an earlier Linux focus. Returning to Linux restores its normal review path. + +`review.Worker` remains the only owner of the flat cursor and capture ordering. +Before each Wine review command, obtain a fresh bounded snapshot, updating a +stable owned tree so live object generations and independent review offsets +survive text updates. Refreshing only on focus events would miss streaming +MUD output. Reuse existing capture/result queues and presentation methods; +native text is not fetched on the input or event loop. + +Accept a result only if the foreground epoch, registered provider, focus, +window lifetime, and source revision still match. Focus change, provider death, +helper restart, window destruction, or lock activation retires Wine review +ownership and queued work. Repeated focus notifications for the same live +control preserve review position. Empty focused edits remain valid anchors so +review can reach other visible content in the window. + +## Bounds, failures, and presentation + +Use a 500 ms total Wine capture deadline, at most 4096 nodes, 256 visible text +ranges per control, 4096 code points per visual line, 65536 captured code points +per control, and 1 MiB of text per window. Enforce a 2 MiB serialized snapshot +limit including metadata. Exhaustion returns a limited status; never present a +partial snapshot as a complete window. The existing review command FIFO remains +bounded at 16. Shutdown cancels outstanding D-Bus work and drops late results. + +Unavailable capture, no visible content, and capture limits use existing +complete review messages. Keep Python/current Go's silent movement boundaries, +spelling and phonetic sequences, and blank/space handling. Review speech and +braille go through the normal queues and settings; no direct speech calls from +the helper. Debug logs include provider/source identities, epochs, native +UTF-16 and converted offsets, capture timing, command, final speech, and braille +text. Password contents never enter debug logs. + +## Verification and acceptance + +Write failing behavioral checks before production changes: + +* Helper fixtures, 32-bit and 64-bit: visible wrapped text and blank lines, + Unicode/surrogate conversion, read-only multiline output, empty command field, + captions/native control fallback, hidden/clipped content, password exclusion, + unchanged application caret/selection/focus, and capture limits. +* Go protocol and review tests: real private-session D-Bus registration/capture, + sender association and malformed payload rejection; all listed review units, + spelling/phonetics and quiet boundaries; text updates preserving position; + independent provider identities for two prefixes; focus switches, reused + handles, provider death, helper restarts, stale replies, timeout/queue limits, + and cancellation. +* Existing helper menu/editor/list regressions and focused Go review, remote, + Wine, speech and braille tests with race checks. +* Private Xorg fixture driving physical X11 key events through the actual Go + command router and both Wine architectures. Verify key press/release balance, + focus switching to Linux, input responsiveness during a delayed capture, and + no application caret movement caused by review keys. + +Run private displays sequentially. Recorded speech/braille requests establish +queue behavior, not audible speech or physical braille acceptance. Live +MUSHclient acceptance must check the user's existing desktop review keys in the +command field and output notepad, repeated-word spelling, reading while output +arrives, plugin dialogs, and returning to Linux. Install matching tested helper +and reader builds only after verification; preserve backups and game state. + +## Work locations and delivery + +Helper changes belong in this standalone repository. Reader integration and +its tests belong in `/home/deedra/git/cthulhu-go`, with focused evidence in its +Wine testing note and the corresponding gap updated in `MIGRATION_PLAN.md`. +The reader directory and installed binaries are outside this session's writable +roots, so writes there require tool approval. Existing uncommitted helper +changes and `cthulhu.log` must be preserved. + +After user approval of this document, write a reviewable implementation plan +and obtain the required plan/execution decision. Keep source changes available +for human review; do not submit them automatically. + +## Reference evidence + +Local behavior references: Cthulhu Python `flat_review_presenter.py` for desktop +bindings, independent navigation, spelling, braille and silent boundaries; +Cthulhu-Go `flat_review.go`, `flat_worker.go`, `flat.go`, `flat_layout.go`, +`AccessibleTree.Replace`, foreground/title ownership, and remote authorization. +The Python Wine bridge provides speech events, not a reusable review provider. + +Microsoft documents [Edit character positions](https://learn.microsoft.com/en-us/windows/win32/controls/em-posfromchar) +and [Edit visible-line/text operations](https://github.com/MicrosoftDocs/win32/blob/docs/desktop-src/Controls/edit-controls-text-operations.md). +These establish the native capture approach, not Wine or MUSHclient acceptance. diff --git a/edit_hook.c b/edit_hook.c index 3386b24..63a4b21 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -194,7 +194,10 @@ 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)) return FALSE; +#ifdef _WIN64 + // Scintilla's separate encoded-position reader remains 64-bit. if (!lstrcmpiW(class_name, L"Scintilla")) return wine_access_watch_scintilla(window); +#endif 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. @@ -221,7 +224,9 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, HWND focus = GetFocus(); if (wine_access_watch_edit(focus)) SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); +#ifdef _WIN64 wine_access_scintilla_input(focus, (const MSG *)lp); +#endif } return CallNextHookEx(NULL, code, wp, lp); } diff --git a/main.cpp b/main.cpp index 8d77cbd..1f3fa69 100644 --- a/main.cpp +++ b/main.cpp @@ -7,6 +7,7 @@ #include "list_view.h" #include "control_class.h" #include "native_items.h" +#include "review_service.h" #include #include @@ -28,6 +29,8 @@ constexpr auto OBJECT_PATH = "/org/stormux/Cthulhu1/Service"; constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service"; constexpr int DBUS_TIMEOUT_MS = 500; GDBusConnection *connection = nullptr; +ReviewIdentityStore reviewIdentities; +ReviewService reviewService; std::deque menuEvents; std::deque textEvents; bool editReaderActive = false; @@ -710,6 +713,10 @@ HANDLE start_menu_host32(HWND bridge) { void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, LONG objectId, LONG childId, DWORD, DWORD) { + if (event == EVENT_OBJECT_DESTROY) reviewIdentities.destroyed(window,objectId,childId); + else if (event == EVENT_OBJECT_REORDER) reviewIdentities.reordered(window,objectId); + if (event == EVENT_OBJECT_FOCUS || event == EVENT_SYSTEM_FOREGROUND) + reviewService.focus_changed(reviewIdentities.observe_foreground()); if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE && event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION && event != EVENT_OBJECT_STATECHANGE) @@ -975,6 +982,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { } HWND window = nullptr; + reviewService.start(connection,reviewIdentities); bool controllerActive = false; if (controllerEnabled && FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) { @@ -1040,12 +1048,14 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr && message.wParam == listTimer) { poll_control_focus(); + reviewService.focus_changed(reviewIdentities.observe_foreground()); continue; } TranslateMessage(&message); DispatchMessageW(&message); } if (listTimer) KillTimer(nullptr, listTimer); + reviewService.stop(); if (hook != nullptr) UnhookWinEvent(hook); if (menuHook != nullptr) diff --git a/menu_hook.c b/menu_hook.c index a0ffda0..7967e6c 100644 --- a/menu_hook.c +++ b/menu_hook.c @@ -1,8 +1,6 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include "menu_bridge.h" -#ifndef WINE_ACCESS_MENU_ONLY #include "edit_hook.h" -#endif static void menu_text(WCHAR *output, UINT capacity, const WCHAR *input) { UINT used = 0; @@ -22,9 +20,7 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp, LPARAM lp) { if (code >= 0) { const CWPSTRUCT *message = (const CWPSTRUCT *)lp; -#ifndef WINE_ACCESS_MENU_ONLY if (message->message == WM_SETFOCUS) wine_access_watch_edit(message->hwnd); -#endif if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP) { HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL); HWND owner = GetAncestor(message->hwnd, GA_ROOT); diff --git a/menu_host32.c b/menu_host32.c index 4d650e6..07564a0 100644 --- a/menu_host32.c +++ b/menu_host32.c @@ -14,7 +14,7 @@ static DWORD foreground_thread32(void) { } // Hook DLLs must match the application's architecture. This small PE host -// supplies the 32-bit menu hook without requiring 32-bit Linux GLib/Winelib. +// supplies 32-bit menu/editor hooks without requiring 32-bit Linux GLib/Winelib. int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { (void)instance; (void)previous; (void)show; char *end; @@ -39,11 +39,13 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) lstrcpyW(name, L"wine-access-menu32.dll"); HMODULE module = LoadLibraryW(path); HOOKPROC callback = module ? (HOOKPROC)GetProcAddress(module, "WineAccessMenuHook") : NULL; + HOOKPROC inputCallback = module ? (HOOKPROC)GetProcAddress(module, "WineAccessInputHook") : NULL; HHOOK hook = NULL; + HHOOK inputHook = NULL; DWORD hookedThread = 0; - if (!callback) - fprintf(stderr, "wine-access: 32-bit menu hook unavailable (error %lu)\n", GetLastError()); - if (callback) { + if (!callback || !inputCallback) + fprintf(stderr, "wine-access: 32-bit menu/editor hooks unavailable (error %lu)\n", GetLastError()); + if (callback && inputCallback) { while (IsWindow(bridge)) { // Wine's mixed-architecture global hook chain can suppress the 64-bit // hook. Attach only to the foreground 32-bit UI thread, and release it @@ -51,8 +53,15 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) DWORD thread = foreground_thread32(); if (thread != hookedThread) { if (hook) UnhookWindowsHookEx(hook); + if (inputHook) UnhookWindowsHookEx(inputHook); hook = thread ? SetWindowsHookExW(WH_CALLWNDPROC, callback, module, thread) : NULL; - hookedThread = hook ? thread : 0; + inputHook = thread ? SetWindowsHookExW(WH_GETMESSAGE, inputCallback, module, thread) : NULL; + hookedThread = hook && inputHook ? thread : 0; + if (!hookedThread) { + if (hook) UnhookWindowsHookEx(hook); + if (inputHook) UnhookWindowsHookEx(inputHook); + hook = inputHook = NULL; + } } DWORD result = MsgWaitForMultipleObjects(1, &parent, FALSE, 100, QS_ALLINPUT); if (result == WAIT_OBJECT_0 || result == WAIT_FAILED) break; @@ -63,8 +72,9 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) } } if (hook) UnhookWindowsHookEx(hook); + if (inputHook) UnhookWindowsHookEx(inputHook); } if (module) FreeLibrary(module); CloseHandle(parent); - return callback ? 0 : 1; + return callback && inputCallback ? 0 : 1; } diff --git a/review_capture.cpp b/review_capture.cpp new file mode 100644 index 0000000..ffc9316 --- /dev/null +++ b/review_capture.cpp @@ -0,0 +1,766 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "review_capture.h" +#include "control_class.h" +#include "native_query.h" +#include "review_x11.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// DF_WINE_VIRTUAL_DESKTOP from Wine's ntuser.h (that internal C header is not +// C++ compatible). GetUserObjectInformation returns these desktop flags. +constexpr DWORD wine_virtual_desktop = 0x80000000; +// Wine's import libraries do not export this IID as a Unix data symbol. +constexpr GUID accessible_iid{0x618736e0,0x3c3d,0x11cf,{0x81,0x0c,0x00,0xaa,0x00,0x38,0x9b,0x71}}; +constexpr size_t max_source = 1000000, max_text = 1048576, max_nodes = 4096; +std::string utf8(const WCHAR *text, size_t length) { + if (!length) return {}; + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, + static_cast(length), nullptr, 0, nullptr, nullptr); + if (!size) return {}; + std::string value(size, '\0'); + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, + static_cast(length), value.data(), size, nullptr, nullptr); + return value; +} +bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, uint64_t deadline, + DWORD_PTR &result) { + uint64_t now = GetTickCount64(); + return now < deadline && SendMessageTimeoutW(window, message, wp, lp, + SMTO_ABORTIFHUNG | SMTO_BLOCK, static_cast(std::min(100, deadline - now)), &result); +} +bool remote_query(HWND window, UINT message, WPARAM wp, LPARAM lp, + uint64_t deadline, DWORD_PTR &result) { + if (GetTickCount64() >= deadline) return false; + // Custom control messages are not marshalled. A timeout does not mean the + // target stopped dereferencing our remote buffer. Wait for real completion + // on this capture STA; the independent dispatcher expires the bus reply. + result=SendMessageW(window,message,wp,lp); + return GetTickCount64() < deadline; +} +bool password(HWND window); +bool read_text(HWND window, uint64_t deadline, size_t limit, std::vector &text, + bool &limited) { + DWORD_PTR size = 0, copied = 0; + if (password(window) || !query(window, WM_GETTEXTLENGTH, 0, 0, deadline, size) || password(window)) return false; + if (size > limit) { limited = true; return false; } + text.assign(size + 1, 0); + if (!query(window, WM_GETTEXT, text.size(), reinterpret_cast(text.data()), deadline, copied) || password(window)) { text.clear(); return false; } + // A grow/shrink between these messages is rejected, never truncated. + DWORD_PTR after = 0; + if (!query(window, WM_GETTEXTLENGTH, 0, 0, deadline, after) || copied != size || after != size) return false; + text.resize(size); + return true; +} +std::string caption(HWND window) { + // A foreign window's caption is held by USER32. Sending WM_GETTEXT here + // could deadlock against an editor hook currently sending WM_COPYDATA to + // the event thread, dropping ordinary focus feedback after its 50 ms wait. + int length=GetWindowTextLengthW(window); + if (length <= 0 || length > 4096) return {}; + std::vector value(length+1); + int copied=GetWindowTextW(window,value.data(),static_cast(value.size())); + auto result = utf8(value.data(),copied); + return result.size() <= 4096 ? result : std::string{}; +} +bool password(HWND window) { + WCHAR cls[128]{}; + GetClassNameW(window, cls, 128); + return wine_access_class_matches(cls, L"Edit") && (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD); +} +bool equal_focus(const ReviewFocus &a, const ReviewFocus &b) { + return a.instance == b.instance && a.window == b.window && a.focus == b.focus && + a.revision == b.revision && a.protected_text == b.protected_text && + EqualRect(&a.bounds, &b.bounds); +} +std::string mnemonic(const std::string &value) { + std::string out; + for (size_t i = 0; i < value.size(); ++i) { + if (value[i] == '&') { + if (i + 1 < value.size() && value[i + 1] == '&') { out += '&'; ++i; } + } else out += value[i]; + } + return out; +} +bool inside(const RECT &child, const RECT &parent) { + return child.right > child.left && child.bottom > child.top && + child.left >= parent.left && child.top >= parent.top && + child.right <= parent.right && child.bottom <= parent.bottom; +} +struct EditLayout { + RECT format{}; + DWORD_PTR first=0, lines=0, font=0; + LONG_PTR style=0; + int horizontal=0, vertical=0; +}; +bool edit_layout(HWND window, uint64_t deadline, EditLayout &layout) { + DWORD_PTR answer=0; + layout.style=GetWindowLongPtrW(window,GWL_STYLE); + layout.horizontal=GetScrollPos(window,SB_HORZ); + layout.vertical=GetScrollPos(window,SB_VERT); + for (auto message:{EM_GETRECT,EM_GETFIRSTVISIBLELINE,EM_GETLINECOUNT}) { + LPARAM lp=message == EM_GETRECT ? reinterpret_cast(&layout.format) : 0; + DWORD_PTR *result=message == EM_GETFIRSTVISIBLELINE ? &layout.first : + message == EM_GETLINECOUNT ? &layout.lines : &answer; + if (!query(window,message,0,lp,deadline,*result)) { + return false; + } + } + // Wine reports failure when marshalling a null WM_GETFONT result. Null is + // the native default font; preserve the optional query used by Edit capture. + query(window,WM_GETFONT,0,0,deadline,layout.font); + return GetTickCount64() < deadline; +} +bool same_layout(const EditLayout &a, const EditLayout &b) { + return EqualRect(&a.format,&b.format) && a.first == b.first && a.lines == b.lines && + a.font == b.font && a.style == b.style && a.horizontal == b.horizontal && a.vertical == b.vertical; +} +struct SourceRead { + HWND window; std::vector text; RECT bounds; + std::optional layout{}; + std::vector> positions{}; +}; +struct AccessibleRead { + IAccessible *object; + LONG child; + std::string name, value; + bool read_value; + RECT bounds; +}; +struct ListRead { HWND window; size_t index; std::vector text; RECT bounds; int column = -1; }; +struct ReviewItem32 { + uint32_t mask; int32_t item, subitem; uint32_t state, state_mask, text; int32_t capacity; +}; +struct ReviewItem64 { + uint32_t mask; int32_t item, subitem; uint32_t state, state_mask, padding; + uint64_t text; int32_t capacity; uint32_t padding2; +}; +static_assert(offsetof(ReviewItem32,text) == 20); +static_assert(offsetof(ReviewItem64,text) == 24); +struct Capture { + ReviewIdentityStore &store; + ReviewCapture result; + uint64_t deadline; + size_t source_units = 0, text_bytes = 0; + size_t serialized_estimate = 512; + std::vector reads; + std::vector accessible_reads; + std::vector list_reads; + std::unordered_set native; + size_t visited = 0; + bool failed = false; + ~Capture() { + for (auto &read : accessible_reads) read.object->Release(); + } + + void fail(const char *status) { failed = true; result.status = status; } + bool add(ReviewNode node) { + if (GetTickCount64() >= deadline) { fail("unavailable"); return false; } + if (result.nodes.size() >= max_nodes || node.name.size() > 4096 || node.id.size() > 256) { + fail("limited"); return false; + } + text_bytes += node.name.size(); + serialized_estimate += 512 + node.id.size() + node.parent.size() + node.role.size() + node.name.size(); + for (const auto &range : node.ranges) { + text_bytes += range.text.size(); serialized_estimate += 128 + range.text.size(); + } + // Reserve ample metadata space within the 2 MiB serialized bound. + if (text_bytes > max_text || serialized_estimate > 1800000) { + fail("limited"); return false; + } + result.nodes.push_back(std::move(node)); + return true; + } + bool edit(HWND window, ReviewNode &node, const RECT &clip) { + // Exclude protected text before WM_GETTEXT or any provider value call. + if (node.protected_text) return true; + std::vector text; + bool limited = false; + if (!read_text(window, deadline, max_source - source_units, text, limited)) { + fail(limited ? "limited" : "stale"); return false; + } + source_units += text.size(); + EditLayout layout{}; + if (!edit_layout(window,deadline,layout)) { fail("unavailable"); return false; } + std::vector> positions; + auto position_at = [&](size_t index, DWORD_PTR &position) { + if (!query(window,EM_POSFROMCHAR,index,0,deadline,position) || + position == static_cast(-1)) return false; + positions.emplace_back(index,position); + return true; + }; + // Map every native position, including invisible prefixes. Interior + // surrogate boundaries stay invalid rather than splitting a character. + std::vector offsets(text.size() + 1, UINT64_MAX); + uint64_t runes = 0; + for (size_t i = 0; i < text.size();) { + offsets[i] = runes++; + if (text[i] >= 0xd800 && text[i] <= 0xdbff) { + if (i + 1 >= text.size() || text[i + 1] < 0xdc00 || text[i + 1] > 0xdfff) { + fail("unavailable"); return false; + } + i += 2; + } else { + if (text[i] >= 0xdc00 && text[i] <= 0xdfff) { fail("unavailable"); return false; } + ++i; + } + } + offsets.back() = runes; + node.text_known = true; + DWORD start = 0, end = 0; DWORD_PTR answer = 0; + if (query(window, EM_GETSEL, reinterpret_cast(&start), reinterpret_cast(&end), deadline, answer) && + end < offsets.size() && offsets[end] != UINT64_MAX) { + node.caret_known = true; node.caret_utf16 = end; node.caret = offsets[end]; + } + RECT format=layout.format; + POINT origin{0, 0}; ClientToScreen(window, &origin); + RECT client{}; GetClientRect(window, &client); OffsetRect(&client, origin.x, origin.y); + RECT visible{}; + RECT original_bounds{}; GetWindowRect(window, &original_bounds); + if (!IntersectRect(&visible, &client, &clip)) { + reads.push_back({window,std::move(text),original_bounds,layout,{}}); return true; + } + HDC dc = GetDC(window); + if (!dc) { fail("unavailable"); return false; } + DWORD_PTR font = layout.font; + HGDIOBJ old = font ? SelectObject(dc, reinterpret_cast(font)) : nullptr; + TEXTMETRICW metrics{}; GetTextMetricsW(dc, &metrics); + DWORD_PTR lines=layout.lines, first=layout.first; + bool good=true; + const bool multiline = GetWindowLongPtrW(window, GWL_STYLE) & ES_MULTILINE; + if (!multiline) first = 0; // Single-line message returns character index. + size_t captured = 0; + for (size_t line = first; good && line < lines && !failed; ++line) { + DWORD_PTR index = 0, length = 0, position = 0; + good = query(window, EM_LINEINDEX, line, 0, deadline, index) && + query(window, EM_LINELENGTH, index, 0, deadline, length); + if (!good || index > text.size() || length > text.size() - index) { fail("stale"); break; } + // EM_POSFROMCHAR has no position for an empty final row. Its visual + // line geometry follows the control's actual font and format rectangle. + if (index < text.size()) { + good=position_at(index,position); + if (!good) { fail("unavailable"); break; } + } else { + position = MAKELONG(format.left, format.top + (line - first) * metrics.tmHeight); + } + LONG x = static_cast(LOWORD(position)) + origin.x; + LONG y = static_cast(HIWORD(position)) + origin.y; + if (y >= visible.bottom) break; + SIZE extent{}; + if (length) GetTextExtentPoint32W(dc, text.data() + index, static_cast(length), &extent); + RECT bounds{x, y, x + std::max(1, extent.cx), y + metrics.tmHeight}; + size_t finish = index + length; + if (length) { + size_t last=finish-1; + if (offsets[last] == UINT64_MAX) --last; + DWORD_PTR last_position=0; + if (!position_at(last,last_position)) { good=false; break; } + SIZE last_extent{}; + GetTextExtentPoint32W(dc,text.data()+last,static_cast(finish-last),&last_extent); + LONG last_y=static_cast(HIWORD(last_position))+origin.y; + if (last_y != y) { fail("stale"); break; } + bounds.right=static_cast(LOWORD(last_position))+origin.x+std::max(1,last_extent.cx); + } + if (length && !inside(bounds,visible)) { + // Read only fully displayed characters. Native positions locate the + // visible substring without scrolling or changing the selection; + // binary searches skip potentially huge horizontally hidden prefixes. + auto next = [&](size_t at) { return at+((text[at]>=0xd800 && text[at]<=0xdbff) ? 2 : 1); }; + auto previous = [&](size_t at) { + --at; if (text[at]>=0xdc00 && text[at]<=0xdfff && at>index) --at; return at; + }; + auto character_bounds = [&](size_t at, RECT &character) { + DWORD_PTR start_position=0, next_position=0; + if (!position_at(at,start_position)) return false; + LONG left=static_cast(LOWORD(start_position))+origin.x; + LONG top=static_cast(HIWORD(start_position))+origin.y; + SIZE size{}; + GetTextExtentPoint32W(dc,text.data()+at,static_cast(next(at)-at),&size); + LONG right=left+std::max(1,size.cx); + if (next(at)(HIWORD(next_position))+origin.y; + LONG next_x=static_cast(LOWORD(next_position))+origin.x; + if (next_y == top && next_x > left) right=next_x; + } + character={left,top,right,top+metrics.tmHeight}; + return true; + }; + RECT first_bounds{}; + size_t begin=index, stop=finish; + if (!multiline) begin=std::min(finish,std::max(index,layout.first)); + if (begin visible.right) { + size_t low=begin, high=stop; + while (low 4096 || captured + count > 65536 || node.ranges.size() >= 256) { fail("limited"); break; } + node.ranges.push_back({offsets[index], offsets[finish], index, finish, + utf8(text.data() + index, finish - index), bounds}); + captured += count; + } + if (old) SelectObject(dc, old); + ReleaseDC(window, dc); + if (!good && !failed) fail("unavailable"); + reads.push_back({window,std::move(text),original_bounds,layout,std::move(positions)}); + return !failed; + } + bool list_item(HWND window, size_t index, std::vector &text, RECT &bounds) { + DWORD_PTR length=0, result=0; + if (!query(window, LB_GETTEXTLEN, index, 0, deadline, length) || static_cast(length) < 0) return false; + if (length > 4096) { fail("limited"); return false; } + text.assign(length+1,0); + if (!query(window, LB_GETTEXT, index, reinterpret_cast(text.data()), deadline, result) || result != length || + !query(window, LB_GETITEMRECT, index, reinterpret_cast(&bounds), deadline, result) || + static_cast(result) == LB_ERR) return false; + text.resize(length); + POINT point{0,0}; ClientToScreen(window,&point); OffsetRect(&bounds,point.x,point.y); + return true; + } + void list(HWND window, const std::string &parent, const RECT &clip) { + LONG_PTR style=GetWindowLongPtrW(window,GWL_STYLE); + if ((style & (LBS_OWNERDRAWFIXED|LBS_OWNERDRAWVARIABLE)) && !(style & LBS_HASSTRINGS)) return; + DWORD_PTR count=0, first=0; + if (!query(window,LB_GETCOUNT,0,0,deadline,count) || static_cast(count) < 0 || + !query(window,LB_GETTOPINDEX,0,0,deadline,first)) { fail("unavailable"); return; } + for (size_t i=first; i text; RECT bounds{}; + if (!list_item(window,i,text,bounds)) { if (!failed) fail("stale"); break; } + if (bounds.top >= clip.bottom) break; + if (!inside(bounds,clip)) continue; + auto value=utf8(text.data(),text.size()); + ReviewNode node; + node.id=parent+"_l"+std::to_string(i); node.parent=parent; node.role="list item"; + node.bounds=bounds; node.name=value; node.text_known=true; + uint64_t runes=0; for (unsigned char c:value) if ((c&0xc0)!=0x80) ++runes; + if (!value.empty()) node.ranges.push_back({0,runes,0,text.size(),value,bounds}); + if (!add(std::move(node))) break; + list_reads.push_back({window,i,std::move(text),bounds}); + } + } + bool table_item(HWND window, size_t index, int column, std::vector &text, RECT &bounds) { + // Common-control messages contain pointers and require memory in the + // target process, including a target-width LVITEM for 32-bit clients. + constexpr size_t offset=128, capacity=4098, bytes=offset+capacity*sizeof(WCHAR); + native_control::RemoteBuffer remote(window,bytes); + if (!remote) return false; + std::vector buffer(bytes,0); + if (remote.is32Bit()) { + ReviewItem32 item{LVIF_TEXT,static_cast(index),column,0,0, + static_cast(remote.address(offset)),capacity}; + std::memcpy(buffer.data(),&item,sizeof(item)); + } else { + ReviewItem64 item{LVIF_TEXT,static_cast(index),column,0,0,0, + remote.address(offset),capacity,0}; + std::memcpy(buffer.data(),&item,sizeof(item)); + } + DWORD_PTR copied=0, answer=0; + if (!remote.write(buffer.data(),bytes) || + !remote_query(window,LVM_GETITEMTEXTW,index,remote.address(),deadline,copied)) return false; + if (copied >= capacity-1 || copied > max_source-source_units) { fail("limited"); return false; } + source_units+=copied; + text.assign(copied+1,0); + if (!remote.read(text.data(),text.size()*sizeof(WCHAR),offset) || text.back() != 0) return false; + text.resize(copied); + bool report=(GetWindowLongPtrW(window,GWL_STYLE)&LVS_TYPEMASK) == LVS_REPORT; + bounds={LVIR_LABEL,column,0,0}; + if (!remote.write(&bounds,sizeof(bounds)) || + !remote_query(window,report ? LVM_GETSUBITEMRECT : LVM_GETITEMRECT,index,remote.address(),deadline,answer) || !answer || + !remote.read(&bounds,sizeof(bounds))) return false; + // Column zero's rectangle includes all report columns on some Wine + // versions. Restrict it to the actual header cell, never a guessed width. + DWORD_PTR header=0; + if (report && + query(window,LVM_GETHEADER,0,0,deadline,header) && header) { + RECT cell{}; + if (!remote.write(&cell,sizeof(cell)) || + !remote_query(reinterpret_cast(header),HDM_GETITEMRECT,column,remote.address(),deadline,answer) || !answer || + !remote.read(&cell,sizeof(cell))) return false; + POINT origin{0,0}; ClientToScreen(reinterpret_cast(header),&origin); + POINT table_origin{0,0}; ClientToScreen(window,&table_origin); + bounds.left=std::max(bounds.left,cell.left+origin.x-table_origin.x); + bounds.right=std::min(bounds.right,cell.right+origin.x-table_origin.x); + } + POINT origin{0,0}; ClientToScreen(window,&origin); OffsetRect(&bounds,origin.x,origin.y); + return true; + } + void table(HWND window, const std::string &parent, const RECT &clip) { + DWORD_PTR count=0, first=0, header=0, columns=1; + if (!query(window,LVM_GETITEMCOUNT,0,0,deadline,count) || + !query(window,LVM_GETTOPINDEX,0,0,deadline,first)) { fail("unavailable"); return; } + if ((GetWindowLongPtrW(window,GWL_STYLE)&LVS_TYPEMASK) == LVS_REPORT && + query(window,LVM_GETHEADER,0,0,deadline,header) && header && + !query(reinterpret_cast(header),HDM_GETITEMCOUNT,0,0,deadline,columns)) { fail("unavailable"); return; } + if (columns > 256 || count > INT32_MAX) { fail("limited"); return; } + for (size_t row=first; row text; RECT bounds{}; + if (!table_item(window,row,column,text,bounds)) { if (!failed) fail("stale"); break; } + if (bounds.top >= clip.bottom) { below=true; break; } + if (!inside(bounds,clip)) continue; + auto value=utf8(text.data(),text.size()); + if (!text.empty() && value.empty()) { fail("unavailable"); break; } + ReviewNode node; + node.id=parent+"_r"+std::to_string(row)+"_c"+std::to_string(column); + node.parent=parent; node.role="table cell"; node.bounds=bounds; node.name=value; + node.text_known=true; + uint64_t runes=0; for (unsigned char c:value) if ((c&0xc0)!=0x80) ++runes; + if (!value.empty()) node.ranges.push_back({0,runes,0,text.size(),value,bounds}); + if (!add(std::move(node))) break; + list_reads.push_back({window,row,std::move(text),bounds,static_cast(column)}); + } + if (below) break; + } + } + void accessible(IAccessible *object, VARIANT child, const std::string &id, + const std::string &parent, const RECT &clip, unsigned depth) { + if (failed) return; + if (GetTickCount64() >= deadline) { fail("unavailable"); return; } + if (++visited > max_nodes || depth > 64) { fail("limited"); return; } + VARIANT state{}; + if (FAILED(object->get_accState(child, &state))) return; + if (state.vt != VT_I4) { VariantClear(&state); return; } + const LONG flags = state.lVal; VariantClear(&state); + if (flags & (STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN)) return; + ReviewNode node; + node.id = id; node.parent = parent; + node.protected_text = (flags & STATE_SYSTEM_PROTECTED) != 0; + LONG x=0, y=0, width=0, height=0; + if (FAILED(object->accLocation(&x, &y, &width, &height, child)) || width <= 0 || height <= 0 || + static_cast(x)+width > INT32_MAX || static_cast(y)+height > INT32_MAX) return; + RECT bounds{x,y,x+width,y+height}; + if (!IntersectRect(&node.bounds, &bounds, &clip)) return; + VARIANT role{}; + object->get_accRole(child, &role); + LONG role_id = role.vt == VT_I4 ? role.lVal : 0; + WCHAR role_name[128]{}; + if (role_id) GetRoleTextW(role_id, role_name, 128); + node.role = node.protected_text ? "password text" : utf8(role_name, lstrlenW(role_name)); + if (node.role.empty()) node.role = "panel"; + VariantClear(&role); + // Do not ask a protected provider for either its name or its value. + if (!node.protected_text) { + BSTR name = nullptr, value = nullptr; + std::string raw_name, raw_value; + object->get_accName(child, &name); + if (name) { + if (SysStringLen(name) > 4096) fail("limited"); + else raw_name = node.name = utf8(name, SysStringLen(name)); + SysFreeString(name); + } + std::string text = node.name; + if (role_id == ROLE_SYSTEM_TEXT || role_id == ROLE_SYSTEM_COMBOBOX) { + object->get_accValue(child, &value); + if (value) { + if (SysStringLen(value) > 4096) fail("limited"); + else raw_value = text = utf8(value, SysStringLen(value)); + SysFreeString(value); + } + } + if (!failed) { + object->AddRef(); + accessible_reads.push_back({object, child.lVal, raw_name, raw_value, + role_id == ROLE_SYSTEM_TEXT || role_id == ROLE_SYSTEM_COMBOBOX, bounds}); + } + if (role_id == ROLE_SYSTEM_PUSHBUTTON || role_id == ROLE_SYSTEM_STATICTEXT) { + node.name = mnemonic(node.name); text = mnemonic(text); + } + // Without per-line MSAA geometry, only a single exposed caption/value + // can be represented faithfully. Never flatten a multi-line provider. + bool caption_only=role_id == ROLE_SYSTEM_PUSHBUTTON || role_id == ROLE_SYSTEM_STATICTEXT; + if (caption_only && !inside(bounds,clip)) node.name.clear(); + if (!caption_only && !failed && !text.empty() && text.find_first_of("\r\n") == std::string::npos && inside(bounds, clip)) { + uint64_t runes=0, units=0; + for (unsigned char c : text) { + if ((c & 0xc0) != 0x80) { ++runes; units += c >= 0xf0 ? 2 : 1; } + } + node.text_known = true; + node.ranges.push_back({0,runes,0,units,text,bounds}); + } + } + const RECT child_clip=node.bounds; + if (failed || !add(std::move(node))) return; + if (child.vt != VT_I4 || child.lVal != CHILDID_SELF) return; + LONG count = 0; + if (FAILED(object->get_accChildCount(&count)) || count <= 0) return; + if (count > static_cast(max_nodes)) { fail("limited"); return; } + std::vector children(count); + LONG obtained = 0; + if (FAILED(AccessibleChildren(object, 0, count, children.data(), &obtained))) return; + for (LONG i=0; iQueryInterface(accessible_iid, reinterpret_cast(&nested)))) { + VARIANT self{}; self.vt=VT_I4; self.lVal=CHILDID_SELF; + accessible(nested, self, child_id, id, child_clip, depth+1); + nested->Release(); + } + } else if (item.vt == VT_I4) accessible(object, item, child_id, id, child_clip, depth+1); + VariantClear(&item); + } + } + void window(HWND window, const std::string &parent, RECT clip, unsigned depth) { + if (failed || !IsWindowVisible(window)) return; + if (depth > 64) { fail("limited"); return; } + RECT bounds{}, clipped{}; + if (!GetWindowRect(window, &bounds) || !IntersectRect(&clipped, &bounds, &clip)) return; + native.insert(window); + ReviewNode node; + node.id = store.identity(window); node.parent = parent; node.bounds = clipped; + WCHAR cls[128]{}; GetClassNameW(window, cls, 128); + node.protected_text = password(window); + bool is_edit = wine_access_class_matches(cls, L"Edit"); + bool button = wine_access_class_matches(cls, L"Button"); + bool label = wine_access_class_matches(cls, L"Static"); + bool list_box = wine_access_class_matches(cls, L"ListBox"); + bool list_view = wine_access_class_matches(cls, L"SysListView32"); + node.role = is_edit ? (node.protected_text ? "password text" : "text") : + button ? "push button" : label ? "label" : "panel"; + if (is_edit) { + if (!edit(window, node, clipped)) return; + } else if (button || label) { + std::vector text; bool limited = false; + if (!read_text(window, deadline, 4096, text, limited)) { fail(limited ? "limited" : "unavailable"); return; } + auto value=utf8(text.data(),text.size()); + node.name = label && (GetWindowLongPtrW(window,GWL_STYLE)&SS_NOPREFIX) ? value : mnemonic(value); + // Captions have no text-position API. Existing flat review represents + // their decoded accessible name using the control's native bounds; + // keep offsets unknown instead of inventing Edit-style UTF-16 ranges. + if (!inside(bounds,clip)) node.name.clear(); + reads.push_back({window, std::move(text), bounds}); + } + const auto id = node.id; + if (!add(std::move(node))) return; + RECT item_clip{}; GetClientRect(window,&item_clip); + POINT item_origin{0,0}; ClientToScreen(window,&item_origin); OffsetRect(&item_clip,item_origin.x,item_origin.y); + if (IntersectRect(&item_clip,&item_clip,&clipped)) { + if (list_box) list(window,id,item_clip); + if (list_view) table(window,id,item_clip); + } + if (!is_edit && !button && !label && !list_box && !list_view) { + IAccessible *object = nullptr; + if (SUCCEEDED(AccessibleObjectFromWindow(window, OBJID_CLIENT, accessible_iid, reinterpret_cast(&object)))) { + VARIANT self{}; self.vt = VT_I4; self.lVal = CHILDID_SELF; + accessible(object, self, id + "_a", id, clipped, depth + 1); + object->Release(); + } + } + RECT client{}; GetClientRect(window, &client); + POINT point{0,0}; ClientToScreen(window, &point); OffsetRect(&client, point.x, point.y); + if (!IntersectRect(&client, &client, &clipped)) return; + for (HWND child = GetWindow(window, GW_CHILD); child && !failed; child = GetWindow(child, GW_HWNDNEXT)) + this->window(child, id, client, depth + 1); + } +}; +} // namespace + +ReviewIdentityStore::ReviewIdentityStore() : state_(std::make_shared()) { + GUID guid{}; CoCreateGuid(&guid); + char value[80]; + std::snprintf(value, sizeof(value), "%08lx-%04x-%04x-%02x%02x%02x%02x%02x%02x%02x%02x", + static_cast(guid.Data1), guid.Data2, guid.Data3, + guid.Data4[0], guid.Data4[1], guid.Data4[2], guid.Data4[3], guid.Data4[4], guid.Data4[5], guid.Data4[6], guid.Data4[7]); + state_->instance = value; +} +std::string ReviewIdentityStore::identity(HWND window) { + std::lock_guard lock(state_->mutex); + auto &value = state_->identities[window]; + if (value.empty()) { + char token[40]; + std::snprintf(token,sizeof(token),"w%016llx_%016llx", + static_cast(reinterpret_cast(window)), + static_cast(++state_->next)); + value=token; + } + return value; +} +ReviewFocus ReviewIdentityStore::observe_foreground() { + ReviewFocus notice; + HWND window = GetForegroundWindow(); + if (!window) return notice; + DWORD thread = GetWindowThreadProcessId(window, nullptr); + GUITHREADINFO gui{}; gui.cbSize = sizeof(gui); + HWND focus = GetGUIThreadInfo(thread, &gui) && gui.hwndFocus ? gui.hwndFocus : window; + if (GetAncestor(focus, GA_ROOT) != window) return notice; + notice.instance = state_->instance; notice.window = identity(window); notice.focus = identity(focus); + WCHAR cls[128]{}; GetClassNameW(window,cls,128); + notice.title = caption(window); notice.role = lstrcmpW(cls,L"#32770")==0 ? "dialog" : "window"; + notice.protected_text = password(focus); + // X11 exposes Wine's application client, not its simulated Win32 border. + // Use that same native client rectangle as the root of visible review. + GetClientRect(window, ¬ice.bounds); + POINT origin{0,0}; ClientToScreen(window,&origin); + OffsetRect(¬ice.bounds,origin.x,origin.y); + // Wine's desktop flags distinguish its real X11 root from a virtual + // desktop, whose children include the simulated Win32 window border. + USEROBJECTFLAGS desktop_flags{}; + if (GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()),UOI_FLAGS, + &desktop_flags,sizeof(desktop_flags),nullptr) && + (desktop_flags.dwFlags & wine_virtual_desktop)) + GetWindowRect(window,¬ice.bounds); + RECT actual{}; POINT native_origin{}; + if (review_x11_geometry(window,actual,native_origin)) notice.bounds=actual; + { + std::lock_guard lock(state_->mutex); + if (state_->window != notice.window || state_->focus != notice.focus) { + ++state_->revision; state_->window = notice.window; state_->focus = notice.focus; + } + notice.revision = state_->revision; + } + return notice; +} +void ReviewIdentityStore::destroyed(HWND window, LONG object, LONG child) { + if (object == OBJID_CARET || object == OBJID_CURSOR) return; + if (object == OBJID_WINDOW && child != CHILDID_SELF) return; + std::lock_guard lock(state_->mutex); + auto found = state_->identities.find(window); + if (found == state_->identities.end()) return; + if (found->second == state_->focus || found->second == state_->window) ++state_->revision; + state_->identities.erase(found); +} +void ReviewIdentityStore::reordered(HWND window, LONG object) { destroyed(window, object, 0); } +void ReviewIdentityStore::retire() { + std::lock_guard lock(state_->mutex); + state_->identities.clear(); state_->window.clear(); state_->focus.clear(); ++state_->revision; +} +ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocus &expected, uint64_t deadline) { + Capture capture{store, {}, deadline, 0, 0, 512, {}, {}, {}, {}, 0, false}; + capture.result.source = expected; + auto initial = store.observe_foreground(); + if (expected.window.empty() || !equal_focus(expected, initial)) { + capture.result.status = "stale"; return capture.result; + } + HWND window = GetForegroundWindow(); + RECT actual{}; POINT native_origin{}; + const bool physical_geometry=review_x11_geometry(window,actual,native_origin); + if (physical_geometry && !EqualRect(&actual,&expected.bounds)) { + capture.result.status="stale"; return capture.result; + } + ReviewNode root; + root.id = expected.window; root.role = expected.role; root.name = expected.title; root.bounds = expected.bounds; + capture.add(std::move(root)); + RECT client{}; GetClientRect(window, &client); + POINT point{0,0}; ClientToScreen(window, &point); OffsetRect(&client, point.x, point.y); + RECT physical_clip=expected.bounds; + OffsetRect(&physical_clip,-native_origin.x,-native_origin.y); + IntersectRect(&client,&client,&physical_clip); + for (HWND child = GetWindow(window, GW_CHILD); child && !capture.failed; child = GetWindow(child, GW_HWNDNEXT)) + capture.window(child, expected.window, client, 1); + // Reread the entire bounded Edit/caption source: checking only its length + // would miss an equal-length replacement and permit a mixed snapshot. + for (const auto &read : capture.reads) { + if (capture.failed) break; + std::vector current; bool limited = false; + RECT bounds{}; + if (password(read.window) || !IsWindowVisible(read.window) || !GetWindowRect(read.window, &bounds) || + !EqualRect(&read.bounds, &bounds)) { capture.fail("stale"); break; } + if (read.text.size() > max_source - capture.source_units) { capture.fail("limited"); break; } + capture.source_units += read.text.size(); + if (!read_text(read.window, deadline, read.text.size(), current, limited) || current != read.text) capture.fail("stale"); + if (read.layout && !capture.failed) { + EditLayout layout{}; + if (!edit_layout(read.window,deadline,layout) || !same_layout(*read.layout,layout)) capture.fail("stale"); + for (const auto &[index,expected_position]:read.positions) { + if (capture.failed) break; + DWORD_PTR position=0; + if (!query(read.window,EM_POSFROMCHAR,index,0,deadline,position) || position != expected_position) capture.fail("stale"); + } + } + } + for (const auto &read : capture.list_reads) { + if (capture.failed) break; + std::vector text; RECT bounds{}; + bool read_ok=read.column < 0 ? capture.list_item(read.window,read.index,text,bounds) : + capture.table_item(read.window,read.index,read.column,text,bounds); + if (!IsWindowVisible(read.window) || !read_ok || + text != read.text || !EqualRect(&bounds,&read.bounds)) capture.fail("stale"); + } + for (const auto &read : capture.accessible_reads) { + if (capture.failed) break; + if (GetTickCount64() >= deadline) { capture.fail("unavailable"); break; } + VARIANT child{}, state{}; child.vt = VT_I4; child.lVal = read.child; + if (FAILED(read.object->get_accState(child, &state)) || state.vt != VT_I4 || + (state.lVal & (STATE_SYSTEM_PROTECTED | STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN))) { + VariantClear(&state); capture.fail("stale"); break; + } + VariantClear(&state); + auto matches = [&](bool value, const std::string &expected_text) { + BSTR text = nullptr; + HRESULT hr = value ? read.object->get_accValue(child, &text) : read.object->get_accName(child, &text); + bool match = (SUCCEEDED(hr) || hr == S_FALSE) && (!text || SysStringLen(text) <= 4096) && + (text ? utf8(text, SysStringLen(text)) : std::string{}) == expected_text; + if (text) SysFreeString(text); + return match; + }; + if (!matches(false, read.name) || (read.read_value && !matches(true, read.value))) capture.fail("stale"); + LONG x=0,y=0,width=0,height=0; + if (FAILED(read.object->accLocation(&x,&y,&width,&height,child)) || + x != read.bounds.left || y != read.bounds.top || + static_cast(x)+width != read.bounds.right || static_cast(y)+height != read.bounds.bottom) capture.fail("stale"); + } + if (!capture.failed && !equal_focus(expected, store.observe_foreground())) capture.fail("stale"); + if (!capture.failed && physical_geometry) { + POINT current_origin{}; + if (!review_x11_geometry(window,actual,current_origin) || !EqualRect(&actual,&expected.bounds) || + current_origin.x != native_origin.x || current_origin.y != native_origin.y) capture.fail("stale"); + } + if (!capture.failed && GetTickCount64() >= deadline) capture.fail("unavailable"); + if (capture.failed) capture.result.nodes.clear(); + else { + for (size_t i=1;i +#include +#include +#include +#include +#include +#include + +struct ReviewFocus { + std::string instance, window, focus, title, role; + uint64_t revision = 0; + bool protected_text = false; + RECT bounds{}; +}; +struct ReviewRange { + uint64_t start = 0, end = 0, utf16_start = 0, utf16_end = 0; + std::string text; + RECT bounds{}; +}; +struct ReviewNode { + std::string id, parent, role, name; + bool protected_text = false, visible = true, text_known = false, caret_known = false; + uint64_t caret = 0, caret_utf16 = 0; + RECT bounds{}; + std::vector ranges; +}; +struct ReviewCapture { + ReviewFocus source; + std::string status = "unavailable"; + std::vector nodes; +}; + +// No native references are retained here. The event and capture STAs share +// only lifetime tokens, protected by this mutex. +class ReviewIdentityStore { +public: + ReviewIdentityStore(); + ReviewFocus observe_foreground(); + void destroyed(HWND, LONG object, LONG child); + void reordered(HWND, LONG object); + void retire(); + std::string identity(HWND); +private: + struct State { + std::mutex mutex; + std::unordered_map identities; + std::string instance, window, focus; + uint64_t next = 0, revision = 0; + }; + // A detached stalled COM worker can retain this state safely until process + // shutdown; it never borrows the event thread's stack or native references. + std::shared_ptr state_; +}; + +ReviewCapture capture_review_window(ReviewIdentityStore&, const ReviewFocus&, + uint64_t deadline_tick); diff --git a/review_service.cpp b/review_service.cpp new file mode 100644 index 0000000..884cbe6 --- /dev/null +++ b/review_service.cpp @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "review_service.h" +#include +#include +#include +#include +#include +#include + +namespace { +constexpr const char *reader = "org.stormux.Cthulhu1.Service"; +constexpr const char *reader_path = "/org/stormux/Cthulhu1/Service"; +constexpr const char *path = "/org/stormux/Cthulhu1/WineReview"; +constexpr const char *xml = R"( + +)"; +GVariant *bounds(const RECT &r) { + return g_variant_new("(iiii)", r.left, r.top, r.right-r.left, r.bottom-r.top); +} +void field(GVariantBuilder &b, const char *key, const std::string &value) { + g_variant_builder_add(&b, "{sv}", key, g_variant_new_string(value.c_str())); +} +void flag(GVariantBuilder &b, const char *key, bool value) { + g_variant_builder_add(&b,"{sv}",key,g_variant_new_boolean(value)); +} +GVariant *serialize(const ReviewCapture &capture) { + GVariantBuilder b, nodes; + g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&b,"{sv}","version",g_variant_new_uint32(1)); + field(b,"instance",capture.source.instance); field(b,"window",capture.source.window); + field(b,"focus",capture.source.focus); field(b,"status",capture.status); + g_variant_builder_add(&b,"{sv}","revision",g_variant_new_uint64(capture.source.revision)); + g_variant_builder_add(&b,"{sv}","bounds",bounds(capture.source.bounds)); + g_variant_builder_init(&nodes,G_VARIANT_TYPE("aa{sv}")); + if (capture.status=="ok") for (const auto &node:capture.nodes) { + GVariantBuilder n,ranges; + g_variant_builder_init(&n,G_VARIANT_TYPE_VARDICT); + field(n,"id",node.id); field(n,"parent",node.parent); field(n,"role",node.role); field(n,"name",node.name); + flag(n,"protected",node.protected_text); flag(n,"visible",node.visible); + flag(n,"text_known",node.text_known); flag(n,"caret_known",node.caret_known); + g_variant_builder_add(&n,"{sv}","caret",g_variant_new_uint64(node.caret)); + g_variant_builder_add(&n,"{sv}","caret_utf16",g_variant_new_uint64(node.caret_utf16)); + g_variant_builder_add(&n,"{sv}","bounds",bounds(node.bounds)); + g_variant_builder_init(&ranges,G_VARIANT_TYPE("a(tttts(iiii))")); + if (!node.protected_text) for (const auto &range:node.ranges) + g_variant_builder_add(&ranges,"(tttts(iiii))",range.start,range.end,range.utf16_start,range.utf16_end, + range.text.c_str(),range.bounds.left,range.bounds.top,range.bounds.right-range.bounds.left,range.bounds.bottom-range.bounds.top); + g_variant_builder_add(&n,"{sv}","ranges",g_variant_builder_end(&ranges)); + g_variant_builder_add_value(&nodes,g_variant_builder_end(&n)); + } + g_variant_builder_add(&b,"{sv}","nodes",g_variant_builder_end(&nodes)); + GVariant *result=g_variant_ref_sink(g_variant_builder_end(&b)); + if (g_variant_get_size(result)>2097152) { + g_variant_unref(result); + ReviewCapture limited; limited.source=capture.source; limited.status="limited"; + return serialize(limited); + } + return result; +} +bool same_notice(const ReviewFocus &a,const ReviewFocus &b) { + return a.instance==b.instance && a.window==b.window && a.focus==b.focus && a.revision==b.revision && + a.title==b.title && a.role==b.role && a.protected_text==b.protected_text && EqualRect(&a.bounds,&b.bounds); +} +struct Job { + ReviewFocus focus; + uint64_t deadline; + GDBusMethodInvocation *invocation; + bool ready=false, replied=false; + ReviewCapture capture; + ~Job() { g_object_unref(invocation); } +}; +} // namespace + +struct ReviewService::State { + explicit State(GDBusConnection *c,ReviewIdentityStore &s):connection(G_DBUS_CONNECTION(g_object_ref(c))),store(s) { + context=g_main_context_new(); loop=g_main_loop_new(context,FALSE); + calls=g_cancellable_new(); + } + ~State() { g_object_unref(calls); g_main_loop_unref(loop); g_main_context_unref(context); g_object_unref(connection); } + GDBusConnection *connection; + ReviewIdentityStore store; + GMainContext *context; + GMainLoop *loop; + GCancellable *calls; + std::mutex mutex; + std::condition_variable changed; + ReviewFocus focus, sent; + std::deque> pending; + std::shared_ptr active; + std::atomic stopping{false}; + bool worker_done=false, ready=false, exported=false; + // These fields belong exclusively to the GLib dispatcher. + std::string owner; + bool same_user=false, registered=false, announcing=false; + uint64_t next_registration=0; + guint object_id=0, owner_subscription=0; + static DWORD WINAPI work(LPVOID); + static DWORD WINAPI dispatch(LPVOID); + + void reply(const std::shared_ptr &job,const ReviewCapture &capture) { + if (job->replied) return; + job->replied=true; + GVariant *data=serialize(capture); + g_dbus_method_invocation_return_value(job->invocation,g_variant_new("(@a{sv})",data)); + } + std::string lookup_owner() { + GError *error=nullptr; + GVariant *value=g_dbus_connection_call_sync(connection,"org.freedesktop.DBus","/org/freedesktop/DBus", + "org.freedesktop.DBus","GetNameOwner",g_variant_new("(s)",reader),G_VARIANT_TYPE("(s)"), + G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error); + std::string result; + if (value) { const gchar *name=nullptr; g_variant_get(value,"(&s)",&name); result=name; g_variant_unref(value); } + if (error) g_error_free(error); + return result; + } + void refresh_owner() { + const auto current=lookup_owner(); + if (current==owner) return; + owner=current; registered=false; same_user=false; sent={}; next_registration=0; + if (owner.empty()) return; + GError *error=nullptr; + GVariant *value=g_dbus_connection_call_sync(connection,"org.freedesktop.DBus","/org/freedesktop/DBus", + "org.freedesktop.DBus","GetConnectionUnixUser",g_variant_new("(s)",owner.c_str()),G_VARIANT_TYPE("(u)"), + G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error); + if (value) { guint uid=0; g_variant_get(value,"(u)",&uid); same_user=uid==getuid(); g_variant_unref(value); } + if (error) g_error_free(error); + } + static void called(GObject *connection,GAsyncResult *result,gpointer data) { + auto state=static_cast*>(data); + GError *error=nullptr; + GVariant *value=g_dbus_connection_call_finish(G_DBUS_CONNECTION(connection),result,&error); + // Completion from an older owner may only allow a retry, never grant + // access or retain that owner's focus as the new provider registration. + (*state)->announcing=false; + gboolean accepted=FALSE; + if (value) { g_variant_get(value,"(b)",&accepted); g_variant_unref(value); } + if (!accepted) (*state)->sent={}; + if (error) g_error_free(error); + delete state; + } + void announce(const std::shared_ptr &self) { + if (announcing || owner.empty() || !same_user || stopping) return; + const uint64_t now=GetTickCount64(); + if (!registered) { + if (now lock(mutex); notice=focus; } + if (!registered || notice.focus.empty() || same_notice(notice,sent)) return; + sent=notice; announcing=true; + g_dbus_connection_call(connection,owner.c_str(),reader_path,reader,"PresentWineReviewFocus", + g_variant_new("(ssstssb(iiii))",notice.instance.c_str(),notice.window.c_str(),notice.focus.c_str(),notice.revision, + notice.title.c_str(),notice.role.c_str(),notice.protected_text,notice.bounds.left,notice.bounds.top, + notice.bounds.right-notice.bounds.left,notice.bounds.bottom-notice.bounds.top), + G_VARIANT_TYPE("(b)"),G_DBUS_CALL_FLAGS_NONE,100,calls,called,new std::shared_ptr(self)); + } + static void method(GDBusConnection *,const gchar *sender,const gchar *,const gchar *,const gchar *, + GVariant *parameters,GDBusMethodInvocation *invocation,gpointer data) { + auto &state=*static_cast(data); + const uint64_t received=GetTickCount64(); + if (!state.same_user || state.owner!=sender || state.lookup_owner()!=sender) { + g_dbus_method_invocation_return_dbus_error(invocation,"org.freedesktop.DBus.Error.AccessDenied","Only the current Cthulhu service may capture Wine review."); return; + } + const char *window=nullptr,*focus=nullptr; guint64 revision=0; + g_variant_get(parameters,"(&s&st)",&window,&focus,&revision); + if (strlen(window)>256 || strlen(focus)>256) { + g_dbus_method_invocation_return_dbus_error(invocation,"org.freedesktop.DBus.Error.InvalidArgs","Review identities exceed their limit."); return; + } + auto job=std::make_shared(); job->deadline=received+500; + job->invocation=G_DBUS_METHOD_INVOCATION(g_object_ref(invocation)); + std::lock_guard lock(state.mutex); + job->focus=state.focus; + ReviewCapture rejected; rejected.source=job->focus; + if (state.stopping) { state.reply(job,rejected); return; } + if (job->focus.window!=window || job->focus.focus!=focus || job->focus.revision!=revision) { + rejected.status="stale"; state.reply(job,rejected); return; + } + if (state.pending.size()>=1) { rejected.status="busy"; state.reply(job,rejected); return; } + state.pending.push_back(job); state.changed.notify_one(); + } + void expire() { + std::lock_guard lock(mutex); + const auto now=GetTickCount64(); + auto finish=[&](const std::shared_ptr &job) { + if (!job || job->replied) return; + if (job->ready && nowdeadline && !stopping) reply(job,job->capture); + else if (now>=job->deadline || stopping) { ReviewCapture expired; expired.source=job->focus; reply(job,expired); } + }; + finish(active); + for (auto &job:pending) finish(job); + while (!pending.empty() && pending.front()->replied) pending.pop_front(); + if (active && active->ready && active->replied) active.reset(); + } +}; + +ReviewService::~ReviewService() { stop(); } +DWORD WINAPI ReviewService::State::work(LPVOID pointer) { + std::unique_ptr> retained(static_cast*>(pointer)); + auto state=*retained; + const HRESULT com=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); + for (;;) { + std::shared_ptr job; + { + std::unique_lock lock(state->mutex); + state->changed.wait(lock,[&] { return state->stopping || (!state->active && !state->pending.empty()); }); + if (state->stopping) break; + job=state->pending.front(); state->pending.pop_front(); state->active=job; + } + ReviewCapture result; result.source=job->focus; + if (SUCCEEDED(com) && GetTickCount64()deadline) result=capture_review_window(state->store,job->focus,job->deadline); + { std::lock_guard lock(state->mutex); job->capture=std::move(result); job->ready=true; } + // Dispatcher returns or expires the result before allowing the next + // native capture. The capture STA never handles D-Bus invocations. + } + if (SUCCEEDED(com)) CoUninitialize(); + { std::lock_guard lock(state->mutex); state->worker_done=true; state->changed.notify_all(); } + return 0; +} +bool ReviewService::start(GDBusConnection *connection,ReviewIdentityStore &store) { + if (state_) return false; + state_=std::make_shared(connection,store); + auto state=state_; + auto worker_state=new std::shared_ptr(state); + worker_=CreateThread(nullptr,0,State::work,worker_state,0,nullptr); + if (!worker_) { delete worker_state; state_.reset(); return false; } + auto dispatch_state=new std::shared_ptr(state); + dispatcher_=CreateThread(nullptr,0,State::dispatch,dispatch_state,0,nullptr); + if (!dispatcher_) { + delete dispatch_state; + { std::lock_guard lock(state->mutex); state->stopping=true; state->changed.notify_all(); } + WaitForSingleObject(worker_,1000); CloseHandle(worker_); worker_=nullptr; + state_.reset(); return false; + } + std::unique_lock lock(state->mutex); + state->changed.wait_for(lock,std::chrono::seconds(1),[&] { return state->ready; }); + return state->exported; +} +DWORD WINAPI ReviewService::State::dispatch(LPVOID pointer) { + std::unique_ptr> retained(static_cast*>(pointer)); + auto state=*retained; + g_main_context_push_thread_default(state->context); + GError *error=nullptr; + GDBusNodeInfo *info=g_dbus_node_info_new_for_xml(xml,&error); + static const GDBusInterfaceVTable vtable{State::method,nullptr,nullptr,{nullptr}}; + if (info) state->object_id=g_dbus_connection_register_object(state->connection,path,info->interfaces[0],&vtable,state.get(),nullptr,&error); + if (error) g_error_free(error); + if (info) g_dbus_node_info_unref(info); + { + std::lock_guard lock(state->mutex); + state->exported=state->object_id!=0; state->ready=true; state->changed.notify_all(); + } + GSource *tick=g_timeout_source_new(10); + g_source_set_callback(tick,[](gpointer data)->gboolean { + auto self=*static_cast*>(data); + self->expire(); self->changed.notify_one(); + static_cast(self); + self->announce(self); + return G_SOURCE_CONTINUE; + },new std::shared_ptr(state),[](gpointer p) { delete static_cast*>(p); }); + g_source_attach(tick,state->context); + state->owner_subscription=g_dbus_connection_signal_subscribe(state->connection,"org.freedesktop.DBus","org.freedesktop.DBus", + "NameOwnerChanged","/org/freedesktop/DBus",reader,G_DBUS_SIGNAL_FLAGS_NONE, + [](GDBusConnection*,const gchar*,const gchar*,const gchar*,const gchar*,GVariant*,gpointer p) { + static_cast(p)->refresh_owner(); + },state.get(),nullptr); + state->refresh_owner(); + g_main_loop_run(state->loop); + g_cancellable_cancel(state->calls); + while (state->announcing) g_main_context_iteration(state->context,TRUE); + state->expire(); + g_source_destroy(tick); g_source_unref(tick); + if (state->owner_subscription) g_dbus_connection_signal_unsubscribe(state->connection,state->owner_subscription); + if (state->object_id) g_dbus_connection_unregister_object(state->connection,state->object_id); + g_main_context_pop_thread_default(state->context); + return 0; +} +void ReviewService::focus_changed(const ReviewFocus &focus) { + if (!state_) return; + std::lock_guard lock(state_->mutex); + state_->focus=focus; +} +void ReviewService::stop() { + auto state=state_; + if (!state) return; + { + std::lock_guard lock(state->mutex); + state->stopping=true; state->changed.notify_all(); + } + g_main_context_invoke(state->context,[](gpointer p)->gboolean { + auto state=static_cast*>(p); + g_main_loop_quit((*state)->loop); delete state; return G_SOURCE_REMOVE; + },new std::shared_ptr(state)); + if (dispatcher_) { WaitForSingleObject(dispatcher_,1000); CloseHandle(dispatcher_); dispatcher_=nullptr; } + std::unique_lock lock(state->mutex); + state->changed.wait_for(lock,std::chrono::milliseconds(100),[&] { return state->worker_done; }); + bool done=state->worker_done; lock.unlock(); + if (worker_) { + if (done) WaitForSingleObject(worker_,100); + CloseHandle(worker_); worker_=nullptr; + } + state_.reset(); +} diff --git a/review_service.h b/review_service.h new file mode 100644 index 0000000..70e18fe --- /dev/null +++ b/review_service.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include "review_capture.h" +#include +#include + +class ReviewService { +public: + ReviewService() = default; + ~ReviewService(); + bool start(GDBusConnection *, ReviewIdentityStore &); + void focus_changed(const ReviewFocus &); + void stop(); +private: + struct State; + std::shared_ptr state_; + HANDLE dispatcher_ = nullptr; + HANDLE worker_ = nullptr; +}; diff --git a/review_x11.cpp b/review_x11.cpp new file mode 100644 index 0000000..19999fd --- /dev/null +++ b/review_x11.cpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "review_x11.h" +// This is a Unix library in a Winelib translation unit, not Windows XCB. +#pragma push_macro("_WIN32") +#undef _WIN32 +#include +#include +#pragma pop_macro("_WIN32") +#include +#include +#include + +namespace { +struct Connection { + xcb_connection_t *value=xcb_connect(nullptr,nullptr); + ~Connection() { if (value) xcb_disconnect(value); } +}; +thread_local Connection connection; +template std::unique_ptr reply(unsigned sequence, uint64_t deadline) { + void *value=nullptr; + xcb_generic_error_t *error=nullptr; + xcb_flush(connection.value); + while (GetTickCount64()(value),&std::free}; + } + auto now=GetTickCount64(); + if (now>=deadline) break; + pollfd fd{xcb_get_file_descriptor(connection.value),POLLIN,0}; + if (poll(&fd,1,static_cast(deadline-now))<=0) break; + } + xcb_discard_reply(connection.value,sequence); + return {nullptr,&std::free}; +} +bool geometry(HWND window, RECT &bounds, uint64_t deadline) { + auto xid=reinterpret_cast(GetPropW(window,L"__wine_x11_whole_window")); + if (!xid || xid>UINT32_MAX || !connection.value || xcb_connection_has_error(connection.value)) return false; + auto size=reply(xcb_get_geometry(connection.value,xid).sequence,deadline); + if (!size || !size->width || !size->height) return false; + auto position=reply( + xcb_translate_coordinates(connection.value,xid,size->root,0,0).sequence,deadline); + if (!position || !position->same_screen) return false; + bounds={position->dst_x,position->dst_y,position->dst_x+size->width,position->dst_y+size->height}; + return true; +} +} +bool review_x11_geometry(HWND window, RECT &bounds, POINT &origin) { + origin={0,0}; + // Metadata reads cannot wait indefinitely behind an unresponsive X server. + const uint64_t deadline=GetTickCount64()+20; + if (!geometry(window,bounds,deadline)) return false; + USEROBJECTFLAGS flags{}; + if (GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()),UOI_FLAGS,&flags,sizeof(flags),nullptr) && + (flags.dwFlags&0x80000000)) { // Wine's DF_WINE_VIRTUAL_DESKTOP. + RECT desktop{}; + if (!geometry(GetDesktopWindow(),desktop,deadline)) return false; + origin={desktop.left,desktop.top}; + } + return true; +} diff --git a/review_x11.h b/review_x11.h new file mode 100644 index 0000000..e9ea198 --- /dev/null +++ b/review_x11.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include + +// Query Wine's actual X11 window, not a title/geometry search. Origin translates +// desktop-local Win32 positions to physical root coordinates in virtual mode. +bool review_x11_geometry(HWND window, RECT &bounds, POINT &origin); diff --git a/tests/README.md b/tests/README.md index 49a750e..28cf844 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,32 @@ # Wine control integration +## Wine flat-review capture + +Build the helper and run `ctest --test-dir build --output-on-failure` on a +private display. The review fixture covers both architectures: visible Edit +text, Unicode offsets before the viewport, wrapping/blank lines, an empty +focus anchor, hidden/password exclusion, password transitions before copying, +clipping, same-length Edit/ListBox/ListView races, native ListView cells, +negative coordinates, source limits and unchanged focus/selection. + +Run `python3 tests/review_integration.py --build-dir build` and its `--32-bit` +variant sequentially. These exercise the exact wire types, actual-owner +authorization, deadline/busy behavior, blocked-provider focus feedback, +shutdown and older-reader compatibility. The matching reader's +`tests/wine_review_x11.py` drives physical XTest keycodes through its real +command router, D-Bus registry and speech/braille queues. Run its Go test with +`CTHULHU_WINE_REVIEW_BUILD=/absolute/build` and +`go test ./internal/app -run NativeWineReviewPhysicalKeys -count=1 -v` on a +private session/display; add `CTHULHU_WINE_REVIEW_32=1` for a 32-bit application. + +Recorded output proves queued text and input consumption, not audible speech +or physical braille. In MUSHclient, focus the plugin's output text window and +check current/previous/next line, words, characters, spelling/phonetics and +window boundaries. Confirm review does not move the native caret; then confirm +ordinary arrows and newly arriving game output still work. Check plugin +dialogs and return to Linux/Fenrir. Custom painted output needs the output +plugin; this protocol supplies no native braille routing or Say All. + Build the project, then run `python3 tests/menu_integration.py --build-dir build` from the repository root in a graphical session. Dependencies are Wine, MinGW's x86_64 and i686 C compilers, PyGObject, and `dbus-run-session`. @@ -35,8 +62,8 @@ and that normal control reading resumes afterward. Test multiple Wine prefixes and reader shutdown separately. Automated event recording does not establish live listening or physical braille acceptance. -32-bit hook support, entirely custom menus without `WM_MENUSELECT`, and full -object review are outside the current implementation. +Entirely custom menus without `WM_MENUSELECT` and full object review are outside +the current implementation. ## BARD Express managed menus @@ -83,6 +110,10 @@ a private X display: python3 tests/menu_integration.py --build-dir build --fixture edit python3 tests/menu_integration.py --build-dir build --fixture edit --late-helper python3 tests/menu_integration.py --build-dir build --fixture edit --restart-helper +python3 tests/menu_integration.py --build-dir build --fixture edit --32-bit +python3 tests/menu_integration.py --build-dir build --fixture edit --32-bit --late-helper +python3 tests/menu_integration.py --build-dir build --fixture edit --32-bit --restart-helper +python3 tests/menu_integration.py --build-dir build --fixture winforms --32-bit python3 tests/menu_integration.py --build-dir build --fixture notepad ``` @@ -90,7 +121,9 @@ The standard Edit fixture checks focus, characters/words/lines, incremental selection and unselection, Backspace/Delete/Clear, read-only controls, password exclusion, surrogate pairs, rapid repeated letters, menu return at the caret, short replacements, CRLF deletion, long-line truncation, Undo snapshots, and -Ctrl+End line reading. Lifecycle variants attach after the control has focus or +Ctrl+End line reading, and Up/Down navigation in a read-only multiline output +control. 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 focus source, matching the Go reader's filtering contract. @@ -100,6 +133,16 @@ checks resulting document text as well as recorded accessibility output. These tests use a recording D-Bus service; they do not run the full reader or prove audible speech, typing echo, physical keys, or a physical braille display. +For live MUSHclient acceptance, install the helper and reopen MUSHclient so the +updated 32-bit DLL can load. In the command field, check Left/Right and +Ctrl+Left/Right feedback without submitting a command. Use Ctrl+Tab to reach +the read-only output notepad supplied by the Aardwolf review-buffer plugin; +check Up/Down line reading and Left/Right characters there, then return to +the command field. Check plugin menus and dialogs still work, and repeat after +restarting the reader. These hooks provide caret feedback; independent review +commands use the optional flat-review protocol described above. Native braille +routing into Wine text remains unsupported. + The earlier 2026-10-10 helper fixtures and Go package tests did not establish that Go accepted editor `text` events. The reader follow-up adds that support, focused name/state handling, repeated-action and password/focus tests, and a @@ -222,7 +265,7 @@ synthetic accessibility events, button roles, return to the checked row, and empty-list state reset. The existing 32-bit plain-list regression also passed. These are private D-Bus recordings; audible GoldWave speech and physical braille remain a live acceptance check after installing the helper -and restarting Cthulhu. The fix does not add 32-bit menu or editor hooks. +and restarting Cthulhu. List queries are independent of the menu/editor hooks. ## Native tabs, trees and named clients diff --git a/tests/edit_fixture.c b/tests/edit_fixture.c index f77dc23..a170ae5 100644 --- a/tests/edit_fixture.c +++ b/tests/edit_fixture.c @@ -119,6 +119,18 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM case 30: key(VK_END, TRUE, FALSE); // whole restored line break; + case 31: + SetWindowTextW(editor, L"readonly first\r\nreadonly second"); + SendMessageW(editor, EM_SETSEL, 0, 0); + SendMessageW(editor, EM_SETREADONLY, TRUE, 0); + SetFocus(password); SetFocus(editor); + break; + case 32: key(VK_DOWN, FALSE, FALSE); break; + case 33: key(VK_UP, FALSE, FALSE); break; + case 34: + key(VK_DELETE, FALSE, FALSE); + if (GetWindowTextLengthW(editor) != 31) ExitProcess(2); + break; default: DestroyWindow(window); break; } diff --git a/tests/menu_integration.py b/tests/menu_integration.py index e88e65f..26768cb 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -24,8 +24,8 @@ 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 not in ("menu", "list", "delphi", "controls"): - parser.error("32-bit checks currently require --fixture menu, list, delphi or controls") +if args.is32Bit and args.fixture not in ("menu", "edit", "winforms", "list", "delphi", "controls"): + parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, delphi or controls") if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"): parser.error("helper lifecycle checks require --fixture edit, winforms or scintilla") if args.fixture == "scintilla" and (not args.notepadpp_exe or not args.notepadpp_exe.is_file()): @@ -278,6 +278,9 @@ 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" + for line in ("readonly first", "readonly second"): + assert any(e[1] == "text" and e[2] == line for e in text_events), f"read-only arrow feedback missing: {line}" + assert "r deleted" not in spoken, "read-only document reported an unsuccessful deletion" 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" diff --git a/tests/review_capture_test.cpp b/tests/review_capture_test.cpp new file mode 100644 index 0000000..17a7116 --- /dev/null +++ b/tests/review_capture_test.cpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include +#include +#include +#include +#include +#include +#if __has_include("../review_capture.h") +#include "../review_capture.h" +#define HAS_CAPTURE 1 +#endif + +static bool check(bool value, const char *message) { + if (!value) std::fprintf(stderr, "FAIL: %s\n", message); + return value; +} + +int main(int argc, char **argv) { + if (argc != 3 || std::string(argv[1]) != "--fixture") return 2; + std::string dos = argv[2]; + if (dos.starts_with('/')) dos = "Z:" + dos; + std::replace(dos.begin(), dos.end(), '/', '\\'); + std::string quoted = "\"" + dos + "\""; + int units = MultiByteToWideChar(CP_UTF8, 0, quoted.c_str(), -1, nullptr, 0); + std::vector command(units); + MultiByteToWideChar(CP_UTF8, 0, quoted.c_str(), -1, command.data(), units); + STARTUPINFOW startup{}; startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, + nullptr, nullptr, &startup, &process)) { + std::fprintf(stderr, "FAIL: fixture starts (%s, Windows error %lu)\n", argv[2], static_cast(GetLastError())); + return 2; + } + HWND window = nullptr; + ULONGLONG deadline = GetTickCount64() + 10000; + while (!(window = FindWindowW(L"WineAccessReviewFixture", nullptr)) && GetTickCount64() < deadline) Sleep(20); + bool ok = check(window != nullptr, "fixture becomes ready"); + if (window) { + Sleep(100); +#ifndef HAS_CAPTURE + ok = check(false, "review capture unavailable"); +#else + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + std::fprintf(stderr, "capture test: COM ready\n"); + ReviewIdentityStore store; + auto capture = [&]() { + auto focus = store.observe_foreground(); + std::fprintf(stderr, "capture test: focus %s\n", focus.focus.c_str()); + return capture_review_window(store, focus, GetTickCount64() + 500); + }; + auto joined = [](const ReviewCapture &data) { + std::string all; + for (const auto &node : data.nodes) { + all += node.name; + for (const auto &range : node.ranges) all += range.text + "\n"; + } + return all; + }; + auto data = capture(); + std::fprintf(stderr, "capture test: initial %s\n", data.status.c_str()); + ok &= check(data.status == "ok", "initial capture succeeds"); + const auto text = joined(data); + ok &= check(text.find("alpha beta") != std::string::npos, "visible Edit text"); + ok &= check(text.find("output second") != std::string::npos, "read-only output text"); + ok &= check(text.find("Open & close") != std::string::npos, "caption mnemonics and literal ampersand"); + ok &= check(text.find("native & item") != std::string::npos, "native MSAA list items retain literal ampersands"); + ok &= check(text.find("private-review-secret") == std::string::npos && text.find("hidden-review-secret") == std::string::npos, + "password and hidden contents excluded"); + ok &= check(SendMessageW(window, WM_APP + 11, 0, 0), "review preserves focus and selection"); + for (int mode : {1, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 17, 18, 21, 22, 23, 24, 25}) { + std::fprintf(stderr, "capture test: mode %d\n", mode); + SendMessageW(window, WM_APP + 10, mode, 0); + auto result = capture(); + const auto value = joined(result); + if (mode == 7) ok &= check(result.status == "limited" && result.nodes.empty(), "source limit rejects partial capture"); + else if (mode == 9) ok &= check(result.status == "stale" && result.nodes.empty(), "same-length update during capture rejects mixed data"); + else if (mode == 14 || mode == 15 || mode == 18 || mode == 23) ok &= check(result.status == "stale" && result.nodes.empty(), "protection/list/viewport transition rejects old capture"); + else if (mode == 21) ok &= check(result.status != "ok" && result.nodes.empty(), "delayed native call respects capture deadline"); + else ok &= check(result.status == "ok", "configured capture succeeds"); + ok &= check(value.find("private-review-secret") == std::string::npos && value.find("hidden-review-secret") == std::string::npos, + "all captures exclude protected/hidden text"); + if (mode == 1) { + bool found = false; + for (const auto &node : result.nodes) for (const auto &range : node.ranges) + if (range.text == "beta") { + found = range.start == 8 && range.end == 12 && range.utf16_start == 9 && range.utf16_end == 13; + } + ok &= check(found, "surrogate before viewport retains absolute rune offsets"); + } + if (mode == 2) { + int wrapped = 0; bool blank = false; + for (const auto &node : result.nodes) for (const auto &range : node.ranges) { + if (range.text.find("wrapped") != std::string::npos) ++wrapped; + if (range.text == "\n") blank = true; + } + ok &= check(wrapped > 1 && blank, "visual wraps and blank lines"); + } + if (mode == 3) ok &= check(value.find("output second") != std::string::npos && !result.source.focus.empty(), "empty command remains review anchor"); + if (mode == 5) ok &= check(value.find("alpha beta") == std::string::npos, "partly clipped text is excluded"); + if (mode == 6) ok &= check(value.find("omega zeta") != std::string::npos && value.find("alpha beta") == std::string::npos, "same-length replacement refreshes text"); + if (mode == 8) ok &= check(result.source.bounds.left < 0, "negative screen coordinates remain valid"); + if (mode == 14) ok &= check(SendMessageW(window,WM_APP + 14,0,0), "password transition is excluded before copying text"); + if (mode == 17) ok &= check(value.find("table & item") != std::string::npos && value.find("ready") != std::string::npos, "visible native ListView cells retain text and ampersands"); + if (mode == 25) ok &= check(value.find("literal & label") != std::string::npos, "SS_NOPREFIX captions retain literal ampersands"); + if (mode == 21) { + Sleep(750); + ok &= check(SendMessageW(window,WM_APP + 15,0,0), "remote buffer lives until the delayed target returns"); + } + if (mode == 22 || mode == 24) { + bool visible=false; + auto field=store.identity(GetDlgItem(window,mode == 22 ? 109 : 110)); + for (const auto &node:result.nodes) if (node.id == field) for (const auto &range:node.ranges) + if (range.utf16_start > 0 && !range.text.empty()) visible=true; + if (!visible) { + HWND control=GetDlgItem(window,mode == 22 ? 109 : 110); + std::fprintf(stderr,"scroll diagnostic first=%lld scroll=%d lines=%lld style=%llx\n", + static_cast(SendMessageW(control,EM_GETFIRSTVISIBLELINE,0,0)),GetScrollPos(control,SB_HORZ), + static_cast(SendMessageW(control,EM_GETLINECOUNT,0,0)),static_cast(GetWindowLongPtrW(control,GWL_STYLE))); + RECT format{}; SendMessageW(control,EM_GETRECT,0,reinterpret_cast(&format)); + auto length=SendMessageW(control,WM_GETTEXTLENGTH,0,0); + std::fprintf(stderr,"scroll length=%lld positions=%llx/%llx format=%d,%d,%d,%d\n",static_cast(length), + static_cast(SendMessageW(control,EM_POSFROMCHAR,0,0)),static_cast(SendMessageW(control,EM_POSFROMCHAR,length-1,0)), + format.left,format.top,format.right,format.bottom); + for (const auto &node:result.nodes) if (node.id == field) for (const auto &range:node.ranges) + std::fprintf(stderr,"scroll range %llu %llu %s\n",static_cast(range.utf16_start),static_cast(range.utf16_end),range.text.c_str()); + } + ok &= check(visible, "horizontally scrolled Edit retains visible text and absolute offsets"); + } + if (mode != 4) ok &= check(SendMessageW(window, WM_APP + 11, 0, 0), "capture leaves caret and focus unchanged"); + } + CoUninitialize(); +#endif + PostMessageW(window, WM_CLOSE, 0, 0); + } + if (WaitForSingleObject(process.hProcess, 3000) != WAIT_OBJECT_0) TerminateProcess(process.hProcess, 2); + CloseHandle(process.hThread); CloseHandle(process.hProcess); + if (ok) std::puts("PASS: native visible Wine review capture and independent caret"); + return ok ? 0 : 1; +} diff --git a/tests/review_fixture.c b/tests/review_fixture.c new file mode 100644 index 0000000..d040585 --- /dev/null +++ b/tests/review_fixture.c @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include +#include +#include +#include + +#define REVIEW_CONFIG (WM_APP + 10) +#define REVIEW_CHECK (WM_APP + 11) +#define REVIEW_CHECK_RIGHT (WM_APP + 12) +#define REVIEW_CHECK_STRICT (WM_APP + 13) +#define REVIEW_PRIVACY_CHECK (WM_APP + 14) +#define REVIEW_REMOTE_CHECK (WM_APP + 15) +static HWND editor, output, password, hidden; +static WCHAR *large; +static WNDPROC original_edit; +static BOOL replace_during_read; +static BOOL stall_accessible; +static BOOL secondary; +static unsigned int key_down, key_up; +static const WCHAR *expected_text = L"alpha beta\r\nsecond line"; +static BOOL protect_during_length, replace_list_during_read; +static unsigned int protected_text_reads; +static WNDPROC original_list; +static WNDPROC original_table; +static BOOL replace_table_during_read; +static BOOL delay_table_read, freed_table_memory, scroll_during_position; +static LRESULT CALLBACK table_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == LVM_GETITEMTEXTW && delay_table_read) { + delay_table_read=FALSE; + Sleep(650); + MEMORY_BASIC_INFORMATION memory={0}; + if (!VirtualQuery((void*)lp,&memory,sizeof(memory)) || memory.State != MEM_COMMIT) { + freed_table_memory=TRUE; + return 0; // Observe the unsafe lifetime without dereferencing freed data. + } + } + LRESULT result=CallWindowProcW(original_table,window,message,wp,lp); + if (message == LVM_GETITEMTEXTW && replace_table_during_read) { + replace_table_during_read=FALSE; + LVITEMW item={0}; item.pszText=L"other & item"; + SendMessageW(window,LVM_SETITEMTEXTW,0,(LPARAM)&item); + } + return result; +} +static LRESULT CALLBACK list_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + LRESULT result = CallWindowProcW(original_list,window,message,wp,lp); + if (message == LB_GETTEXT && replace_list_during_read) { + replace_list_during_read = FALSE; + SendMessageW(window,LB_DELETESTRING,0,0); + SendMessageW(window,LB_INSERTSTRING,0,(LPARAM)L"update & item"); + } + return result; +} +static LRESULT CALLBACK stall_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_GETOBJECT && stall_accessible) { + stall_accessible = FALSE; + Sleep(2000); + } + return DefWindowProcW(window,message,wp,lp); +} +static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_GETTEXT && (GetWindowLongPtrW(window,GWL_STYLE) & ES_PASSWORD)) ++protected_text_reads; + if (message == WM_KEYDOWN) ++key_down; + if (message == WM_KEYUP) ++key_up; + LRESULT result = CallWindowProcW(original_edit, window, message, wp, lp); + if (message == WM_GETTEXTLENGTH && protect_during_length) { + protect_during_length = FALSE; + SendMessageW(window,EM_SETPASSWORDCHAR,L'*',0); + } + if (message == WM_GETTEXT && replace_during_read) { + replace_during_read = FALSE; + SetWindowTextW(window, L"omega zeta\r\nsecond line"); + } + if (message == EM_POSFROMCHAR && scroll_during_position) { + scroll_during_position=FALSE; + SendMessageW(window,EM_LINESCROLL,0,1); + } + return result; +} + +static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + (void)lp; + if (message == WM_TIMER && secondary) { + KillTimer(window,1); + SetForegroundWindow(window); + SetFocus(GetDlgItem(window,105)); + return 0; + } + if (message == REVIEW_CONFIG) { + SendMessageW(editor,EM_SETPASSWORDCHAR,0,0); + protect_during_length = replace_list_during_read = FALSE; + replace_table_during_read=FALSE; + delay_table_read=freed_table_memory=scroll_during_position=FALSE; + protected_text_reads = 0; + key_down = key_up = 0; + expected_text = L"alpha beta\r\nsecond line"; + ShowWindow(GetDlgItem(window,106),SW_SHOW); + ShowWindow(GetDlgItem(window,107),SW_HIDE); + ShowWindow(GetDlgItem(window,109),SW_HIDE); + ShowWindow(GetDlgItem(window,110),SW_HIDE); + ShowWindow(GetDlgItem(window,111),SW_HIDE); + SetWindowTextW(editor, L"alpha beta\r\nsecond line"); + SetWindowTextW(output, L"output first\r\noutput second"); + SendMessageW(editor, EM_SETREADONLY, FALSE, 0); + SendMessageW(editor, EM_SETSEL, 0, 0); + MoveWindow(editor, 20, 35, 320, 90, TRUE); + switch (wp) { + case 1: // surrogate before first displayed row, absolute offsets + SetWindowTextW(editor, L"\U0001f600alpha\r\nbeta\r\nlast"); + SendMessageW(editor, EM_LINESCROLL, 0, 1); + break; + case 2: + SetWindowTextW(editor, L"wrapped words repeated wrapped words repeated wrapped words repeated wrapped words\r\n\r\nlast"); + MoveWindow(editor, 20, 35, 150, 150, TRUE); + break; + case 3: SetWindowTextW(editor, L""); break; + case 4: + SetForegroundWindow(window); + SetFocus(password); + return 1; + case 5: + MoveWindow(editor, 500, 35, 320, 90, TRUE); + break; + case 6: expected_text = L"omega zeta\r\nsecond line"; SetWindowTextW(editor, expected_text); break; + case 7: + large = malloc(1000002 * sizeof(WCHAR)); + if (!large) ExitProcess(3); + // Avoid quadratic native wrapping work in the fixture itself. + for (int i = 0; i < 1000001; ++i) large[i] = i % 32 == 31 ? L'\n' : L'x'; + large[1000001] = 0; + SendMessageW(editor, EM_LIMITTEXT, 2000000, 0); + SetWindowTextW(editor, large); + free(large); large = NULL; + break; + case 8: SetWindowPos(window, NULL, -20, -20, 0, 0, SWP_NOSIZE | SWP_NOZORDER); break; + case 9: replace_during_read = TRUE; break; + case 10: stall_accessible = TRUE; break; + case 11: + SetForegroundWindow(window); + SetFocus(output); + return 1; + case 12: ShowWindow(GetDlgItem(window,106),SW_HIDE); break; + case 14: protect_during_length = TRUE; break; + case 15: replace_list_during_read = TRUE; break; + case 17: ShowWindow(GetDlgItem(window,107),SW_SHOW); break; + case 18: ShowWindow(GetDlgItem(window,107),SW_SHOW); replace_table_during_read=TRUE; break; + case 21: ShowWindow(GetDlgItem(window,107),SW_SHOW); delay_table_read=TRUE; break; + case 22: + ShowWindow(GetDlgItem(window,109),SW_SHOW); + SendMessageW(GetDlgItem(window,109),EM_SETSEL,INT_MAX,INT_MAX); + SendMessageW(GetDlgItem(window,109),EM_SCROLLCARET,0,0); + break; + case 23: + SetWindowTextW(editor,L"first\r\nsecond\r\nthird\r\nfourth\r\nfifth\r\nsixth\r\nseventh\r\neighth\r\nninth\r\ntenth"); + scroll_during_position=TRUE; + break; + case 24: + ShowWindow(GetDlgItem(window,110),SW_SHOW); + SendMessageW(GetDlgItem(window,110),EM_SETSEL,INT_MAX,INT_MAX); + SendMessageW(GetDlgItem(window,110),EM_SCROLLCARET,0,0); + break; + case 25: ShowWindow(GetDlgItem(window,111),SW_SHOW); break; + } + SetForegroundWindow(window); + SetFocus(editor); + return 1; + } + if (message == REVIEW_PRIVACY_CHECK) return protected_text_reads == 0; + if (message == REVIEW_REMOTE_CHECK) return !freed_table_memory; + if (message == REVIEW_CHECK || message == REVIEW_CHECK_RIGHT || message == REVIEW_CHECK_STRICT) { + DWORD start = 0, end = 0; + SendMessageW(editor, EM_GETSEL, (WPARAM)&start, (LPARAM)&end); + if (message == REVIEW_CHECK) return start == 0 && end == 0 && GetFocus() == editor; + WCHAR text[128], output_text[128]; + GetWindowTextW(editor,text,128); GetWindowTextW(output,output_text,128); + if (message == REVIEW_CHECK_RIGHT) + return start == 1 && end == 1 && key_down == 1 && key_up == 1 && GetFocus() == editor && lstrcmpW(text,expected_text) == 0; + return start == 0 && end == 0 && GetFocus() == editor && key_down == 0 && key_up == 0 && + lstrcmpW(text,expected_text) == 0 && lstrcmpW(output_text,L"output first\r\noutput second") == 0; + } + if (message == WM_CLOSE) { DestroyWindow(window); return 0; } + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(window, message, wp, lp); +} + +int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { + (void)previous; (void)show; + secondary=strcmp(args,"--secondary")==0; + if (strncmp(args,"--configure ",12)==0 || strcmp(args,"--check")==0 || strcmp(args,"--check-strict")==0 || strcmp(args,"--check-right")==0 || strcmp(args,"--check-remote")==0 || strcmp(args,"--close")==0) { + HWND target=FindWindowW(L"WineAccessReviewFixture",NULL); + if (!target) return 3; + if (strcmp(args,"--close")==0) { PostMessageW(target,WM_CLOSE,0,0); return 0; } + DWORD_PTR result=0; + UINT message=strcmp(args,"--check")==0 ? REVIEW_CHECK : strcmp(args,"--check-strict")==0 ? REVIEW_CHECK_STRICT : strcmp(args,"--check-right")==0 ? REVIEW_CHECK_RIGHT : strcmp(args,"--check-remote")==0 ? REVIEW_REMOTE_CHECK : REVIEW_CONFIG; + WPARAM mode=message==REVIEW_CONFIG ? (WPARAM)atoi(args+12) : 0; + if (!SendMessageTimeoutW(target,message,mode,0,SMTO_ABORTIFHUNG,3000,&result)) return 4; + return result ? 0 : 5; + } + WNDCLASSW cls = {0}; + cls.hInstance = instance; cls.lpfnWndProc = window_proc; + cls.lpszClassName = secondary ? L"WineAccessReviewSecondary" : L"WineAccessReviewFixture"; + cls.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); + RegisterClassW(&cls); + WNDCLASSW stall=cls; stall.lpszClassName=L"WineReviewStall"; stall.lpfnWndProc=stall_proc; + RegisterClassW(&stall); + HWND window = CreateWindowW(cls.lpszClassName, secondary ? L"Wine secondary fixture" : L"Wine review fixture", + WS_OVERLAPPEDWINDOW, 100, 100, 560, 500, NULL, NULL, instance, NULL); + CreateWindowW(L"Static", L"&Command:", WS_CHILD | WS_VISIBLE, 20, 10, 200, 20, + window, NULL, instance, NULL); + editor = CreateWindowW(L"Edit", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_BORDER | + ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL, 20, 35, 320, 90, window, (HMENU)101, instance, NULL); + original_edit = (WNDPROC)SetWindowLongPtrW(editor, GWLP_WNDPROC, (LONG_PTR)edit_proc); + output = CreateWindowW(L"Edit", L"output first\r\noutput second", WS_CHILD | WS_VISIBLE | + ES_MULTILINE | ES_READONLY | WS_BORDER, 20, 205, 320, 80, window, (HMENU)102, instance, NULL); + password = CreateWindowW(L"Edit", L"private-review-secret", WS_CHILD | WS_VISIBLE | + ES_PASSWORD | WS_BORDER, 20, 320, 200, 25, window, (HMENU)103, instance, NULL); + hidden = CreateWindowW(L"Static", L"hidden-review-secret", WS_CHILD, 20, 350, 200, 25, + window, (HMENU)104, instance, NULL); + CreateWindowW(L"Button", L"&Open && close", WS_CHILD | WS_VISIBLE, 20, 390, 160, 25, + window, (HMENU)105, instance, NULL); + HWND list = CreateWindowW(L"ListBox", L"", WS_CHILD | WS_VISIBLE | LBS_NOTIFY, + 350, 35, 150, 70, window, (HMENU)106, instance, NULL); + original_list = (WNDPROC)SetWindowLongPtrW(list,GWLP_WNDPROC,(LONG_PTR)list_proc); + SendMessageW(list, LB_ADDSTRING, 0, (LPARAM)L"native & item"); + SendMessageW(list, LB_ADDSTRING, 0, (LPARAM)L"another item"); + INITCOMMONCONTROLSEX controls = {sizeof(controls), ICC_LISTVIEW_CLASSES}; + InitCommonControlsEx(&controls); + HWND table = CreateWindowW(WC_LISTVIEWW,L"",WS_CHILD|LVS_REPORT, + 350,200,170,100,window,(HMENU)107,instance,NULL); + LVCOLUMNW column = {0}; column.mask=LVCF_TEXT|LVCF_WIDTH; column.cx=100; column.pszText=L"Plugin"; + SendMessageW(table,LVM_INSERTCOLUMNW,0,(LPARAM)&column); + column.cx=60; column.pszText=L"State"; + SendMessageW(table,LVM_INSERTCOLUMNW,1,(LPARAM)&column); + LVITEMW item={0}; item.mask=LVIF_TEXT; item.pszText=L"table & item"; + SendMessageW(table,LVM_INSERTITEMW,0,(LPARAM)&item); + item.iSubItem=1; item.pszText=L"ready"; + SendMessageW(table,LVM_SETITEMTEXTW,0,(LPARAM)&item); + original_table=(WNDPROC)SetWindowLongPtrW(table,GWLP_WNDPROC,(LONG_PTR)table_proc); + CreateWindowW(L"Edit",L"abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz visible suffix", + WS_CHILD|ES_AUTOHSCROLL|WS_BORDER,20,130,150,25,window,(HMENU)109,instance,NULL); + CreateWindowW(L"Edit",L"abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz visible suffix", + WS_CHILD|ES_MULTILINE|ES_AUTOHSCROLL|WS_HSCROLL|WS_BORDER,20,165,150,50,window,(HMENU)110,instance,NULL); + CreateWindowW(L"Static",L"literal & label",WS_CHILD|SS_NOPREFIX, + 20,365,200,20,window,(HMENU)111,instance,NULL); + CreateWindowW(stall.lpszClassName,L"",WS_CHILD|WS_VISIBLE,350,150,150,20,window,NULL,instance,NULL); + ShowWindow(window, SW_SHOW); + SendMessageW(window, REVIEW_CONFIG, 0, 0); + if (secondary) SetTimer(window,1,400,NULL); + MSG message; + while (GetMessageW(&message, NULL, 0, 0) > 0) { + TranslateMessage(&message); DispatchMessageW(&message); + } + return 0; +} diff --git a/tests/review_integration.py b/tests/review_integration.py new file mode 100644 index 0000000..6ebd333 --- /dev/null +++ b/tests/review_integration.py @@ -0,0 +1,184 @@ +"""Read-only review protocol checks in an isolated Wine prefix/session bus.""" +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +from gi.repository import Gio, GLib + +parser = argparse.ArgumentParser() +parser.add_argument('--build-dir', default='build') +parser.add_argument('--32-bit', dest='is32', 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), *(['--32-bit'] if args.is32 else [])])) +iface = 'org.stormux.Cthulhu1.Service' +path = '/org/stormux/Cthulhu1/Service' +capture_iface = 'org.stormux.Cthulhu1.WineReview' +capture_path = '/org/stormux/Cthulhu1/WineReview' + +def interface_xml(review=True): + methods = {'PresentAccessibleEvent': ['s']*7+['i','i','s']} + if review: + methods.update(RegisterWineReviewProvider=['o','u'], PresentWineReviewFocus=['s','s','s','t','s','s','b','(iiii)']) + return ''+''.join( + ''+''.join('' for kind in kinds)+ + '' for name,kinds in methods.items())+'' + +providers, focuses, events = {}, {}, [] +def record(conn, sender, object_path, interface, method, params, invocation): + data = params.unpack() + accepted = True + if method == 'RegisterWineReviewProvider': + accepted = data == (capture_path, 1) + if accepted: providers[sender] = data + elif method == 'PresentWineReviewFocus': + accepted = sender in providers + if accepted: focuses[sender] = data + else: events.append(data) + invocation.return_value(GLib.Variant('(b)', (accepted,))) + +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) + +def pump_until(predicate, seconds=5): + end = time.monotonic()+seconds + while not predicate() and time.monotonic()