Fix padded Wine review strings
This commit is contained in:
1 parent
a8cb350467
commit
f40e471938
7 files changed
+416
-33
No files matched your search
@@ -268,6 +268,9 @@ 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.
|
||||
Scalar Edit queries are batched so applications with periodic message loops can
|
||||
return visible text within the capture deadline. Native container children are
|
||||
enumerated once, and a maximized MDI document excludes the covered documents.
|
||||
See [tests/README.md](tests/README.md) for checks and live acceptance.
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
+167
-31
@@ -3,6 +3,7 @@
|
||||
#include "control_class.h"
|
||||
#include "native_query.h"
|
||||
#include "review_x11.h"
|
||||
#include "review_query.h"
|
||||
#include <commctrl.h>
|
||||
#include <cstring>
|
||||
#include <oleacc.h>
|
||||
@@ -11,6 +12,7 @@
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <unordered_set>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace {
|
||||
// DF_WINE_VIRTUAL_DESKTOP from Wine's ntuser.h (that internal C header is not
|
||||
@@ -29,6 +31,15 @@ std::string utf8(const WCHAR *text, size_t length) {
|
||||
static_cast<int>(length), value.data(), size, nullptr, nullptr);
|
||||
return value;
|
||||
}
|
||||
std::string accessible_text(const WCHAR *text, size_t length) {
|
||||
// Some MSAA BSTRs retain the window's reported length past its text's
|
||||
// terminating NUL. D-Bus cannot represent those bytes, and the padding
|
||||
// need not contain text. Keep only the terminated label for display;
|
||||
// preserve the original UTF-16 separately for the stale check. Truncate
|
||||
// before conversion: padding may contain malformed surrogate pairs.
|
||||
const auto end=std::find(text,text+length,L'\0');
|
||||
return utf8(text,end-text);
|
||||
}
|
||||
bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, uint64_t deadline,
|
||||
DWORD_PTR &result) {
|
||||
uint64_t now = GetTickCount64();
|
||||
@@ -104,17 +115,12 @@ bool edit_layout(HWND window, uint64_t deadline, EditLayout &layout) {
|
||||
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<LPARAM>(&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);
|
||||
if (!query(window, EM_GETRECT, 0, reinterpret_cast<LPARAM>(&layout.format), deadline, answer)) return false;
|
||||
std::vector<ReviewQuery> fields{{EM_GETFIRSTVISIBLELINE,0},{EM_GETLINECOUNT,0},{WM_GETFONT,0}};
|
||||
if (!review_query_many(window, fields, deadline)) return false;
|
||||
layout.first = fields[0].answer;
|
||||
layout.lines = fields[1].answer;
|
||||
layout.font = fields[2].answer;
|
||||
return GetTickCount64() < deadline;
|
||||
}
|
||||
bool same_layout(const EditLayout &a, const EditLayout &b) {
|
||||
@@ -129,11 +135,13 @@ struct SourceRead {
|
||||
struct AccessibleRead {
|
||||
IAccessible *object;
|
||||
LONG child;
|
||||
std::string name, value;
|
||||
std::vector<WCHAR> name, value;
|
||||
bool read_value;
|
||||
RECT bounds;
|
||||
};
|
||||
struct ListRead { HWND window; size_t index; std::vector<WCHAR> text; RECT bounds; int column = -1; };
|
||||
struct MdiRead { HWND window, active; RECT bounds; };
|
||||
struct CaptionRead { HWND window; std::string text; RECT bounds; };
|
||||
struct ReviewItem32 {
|
||||
uint32_t mask; int32_t item, subitem; uint32_t state, state_mask, text; int32_t capacity;
|
||||
};
|
||||
@@ -152,6 +160,8 @@ struct Capture {
|
||||
std::vector<SourceRead> reads;
|
||||
std::vector<AccessibleRead> accessible_reads;
|
||||
std::vector<ListRead> list_reads;
|
||||
std::vector<MdiRead> mdi_reads;
|
||||
std::vector<CaptionRead> caption_reads;
|
||||
std::unordered_set<HWND> native;
|
||||
size_t visited = 0;
|
||||
bool failed = false;
|
||||
@@ -189,9 +199,13 @@ struct Capture {
|
||||
EditLayout layout{};
|
||||
if (!edit_layout(window,deadline,layout)) { fail("unavailable"); return false; }
|
||||
std::vector<std::pair<size_t,DWORD_PTR>> positions;
|
||||
std::unordered_map<size_t,DWORD_PTR> position_cache;
|
||||
auto position_at = [&](size_t index, DWORD_PTR &position) {
|
||||
auto cached = position_cache.find(index);
|
||||
if (cached != position_cache.end()) { position = cached->second; return true; }
|
||||
if (!query(window,EM_POSFROMCHAR,index,0,deadline,position) ||
|
||||
position == static_cast<DWORD_PTR>(-1)) return false;
|
||||
position_cache.emplace(index,position);
|
||||
positions.emplace_back(index,position);
|
||||
return true;
|
||||
};
|
||||
@@ -235,11 +249,63 @@ struct Capture {
|
||||
bool good=true;
|
||||
const bool multiline = GetWindowLongPtrW(window, GWL_STYLE) & ES_MULTILINE;
|
||||
if (!multiline) first = 0; // Single-line message returns character index.
|
||||
const size_t native_first = first;
|
||||
// Queue scalar lookups together so a frame-based application services a
|
||||
// batch in one turn instead of sleeping between every character query.
|
||||
// Skip rows above a parent clip using the standard Edit's fixed line
|
||||
// height. Keep one extra row for a partly scrolled first line, and retain
|
||||
// native positions below to check which rows are actually fully visible.
|
||||
const LONG line_height = std::max<LONG>(1, metrics.tmHeight);
|
||||
const int64_t hidden_height = static_cast<int64_t>(visible.top)-origin.y-format.top;
|
||||
const size_t skipped = multiline && hidden_height > line_height ?
|
||||
std::min<size_t>(lines > first ? lines-first : 0, hidden_height/line_height-1) : 0;
|
||||
first += skipped;
|
||||
size_t wanted_rows=static_cast<size_t>(std::max<int64_t>(1,
|
||||
static_cast<int64_t>(visible.bottom)-origin.y-format.top)) /
|
||||
line_height+2;
|
||||
wanted_rows = wanted_rows > skipped ? wanted_rows-skipped : 0;
|
||||
if (wanted_rows > 258 && lines > first && lines-first > 258) {
|
||||
if (old) SelectObject(dc, old);
|
||||
ReleaseDC(window, dc);
|
||||
fail("limited"); return false;
|
||||
}
|
||||
size_t row_count=std::min<size_t>(lines>first ? lines-first:0,wanted_rows);
|
||||
std::vector<ReviewQuery> indices,details,last_positions;
|
||||
for (size_t row = 0; row < row_count; ++row) indices.push_back({EM_LINEINDEX, first+row});
|
||||
good = review_query_many(window, indices, deadline);
|
||||
if (good) for (const auto &index : indices) {
|
||||
if (index.answer > text.size()) { good = false; break; }
|
||||
details.push_back({EM_LINELENGTH, index.answer});
|
||||
if (index.answer < text.size()) details.push_back({EM_POSFROMCHAR, index.answer});
|
||||
}
|
||||
if (good) good = review_query_many(window, details, deadline);
|
||||
std::vector<DWORD_PTR> lengths;
|
||||
if (good) {
|
||||
size_t at = 0;
|
||||
for (const auto &index : indices) {
|
||||
auto length = details[at++].answer;
|
||||
lengths.push_back(length);
|
||||
if (length > text.size()-index.answer) { good = false; break; }
|
||||
if (index.answer < text.size()) {
|
||||
auto position = details[at++].answer;
|
||||
position_cache.emplace(index.answer, position);
|
||||
positions.emplace_back(index.answer, position);
|
||||
if (static_cast<short>(HIWORD(position))+origin.y >= visible.bottom) continue;
|
||||
}
|
||||
if (length) {
|
||||
size_t last = index.answer+length-1;
|
||||
if (offsets[last] == UINT64_MAX) --last;
|
||||
last_positions.push_back({EM_POSFROMCHAR, last});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (good) good = review_query_many(window, last_positions, deadline);
|
||||
if (good) for (const auto &position : last_positions)
|
||||
if (position_cache.emplace(position.index, position.answer).second)
|
||||
positions.emplace_back(position.index, position.answer);
|
||||
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);
|
||||
for (size_t line = first; good && line-first < indices.size() && !failed; ++line) {
|
||||
DWORD_PTR index = indices[line-first].answer, length = lengths[line-first], position = 0;
|
||||
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.
|
||||
@@ -247,7 +313,7 @@ struct Capture {
|
||||
good=position_at(index,position);
|
||||
if (!good) { fail("unavailable"); break; }
|
||||
} else {
|
||||
position = MAKELONG(format.left, format.top + (line - first) * metrics.tmHeight);
|
||||
position = MAKELONG(format.left, format.top + (line - native_first) * metrics.tmHeight);
|
||||
}
|
||||
LONG x = static_cast<short>(LOWORD(position)) + origin.x;
|
||||
LONG y = static_cast<short>(HIWORD(position)) + origin.y;
|
||||
@@ -255,6 +321,9 @@ struct Capture {
|
||||
SIZE extent{};
|
||||
if (length) GetTextExtentPoint32W(dc, text.data() + index, static_cast<int>(length), &extent);
|
||||
RECT bounds{x, y, x + std::max<LONG>(1, extent.cx), y + metrics.tmHeight};
|
||||
// Horizontal trimming cannot make a vertically clipped row visible.
|
||||
// Avoid round trips for characters that will be excluded regardless.
|
||||
if (bounds.top < visible.top || bounds.bottom > visible.bottom) continue;
|
||||
size_t finish = index + length;
|
||||
if (length) {
|
||||
size_t last=finish-1;
|
||||
@@ -487,11 +556,14 @@ struct Capture {
|
||||
// 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;
|
||||
std::vector<WCHAR> 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));
|
||||
else {
|
||||
raw_name.assign(name,name+SysStringLen(name));
|
||||
node.name = accessible_text(name,SysStringLen(name));
|
||||
}
|
||||
SysFreeString(name);
|
||||
}
|
||||
std::string text = node.name;
|
||||
@@ -499,7 +571,10 @@ struct Capture {
|
||||
object->get_accValue(child, &value);
|
||||
if (value) {
|
||||
if (SysStringLen(value) > 4096) fail("limited");
|
||||
else raw_value = text = utf8(value, SysStringLen(value));
|
||||
else {
|
||||
raw_value.assign(value,value+SysStringLen(value));
|
||||
text = accessible_text(value,SysStringLen(value));
|
||||
}
|
||||
SysFreeString(value);
|
||||
}
|
||||
}
|
||||
@@ -587,7 +662,32 @@ struct Capture {
|
||||
}
|
||||
if (!is_edit && !button && !label && !list_box && !list_view) {
|
||||
IAccessible *object = nullptr;
|
||||
if (SUCCEEDED(AccessibleObjectFromWindow(window, OBJID_CLIENT, accessible_iid, reinterpret_cast<void**>(&object)))) {
|
||||
DWORD_PTR response=0;
|
||||
// A real provider supplies its marshalled object via WM_GETOBJECT.
|
||||
// Synthesized container trees only repeat the native child hierarchy
|
||||
// already visited here. Keep the standard fallback for native leaves.
|
||||
HRESULT hr=E_FAIL;
|
||||
// Match AccessibleObjectFromWindow's actual message completion: a late
|
||||
// custom reply still owns a marshalled object that must be consumed.
|
||||
bool timely=remote_query(window,WM_GETOBJECT,0xffffffff,OBJID_CLIENT,deadline,response);
|
||||
if (response) hr = ObjectFromLresult(static_cast<LRESULT>(response), accessible_iid, 0, reinterpret_cast<void **>(&object));
|
||||
else if (timely && !GetWindow(window, GW_CHILD))
|
||||
hr = CreateStdAccessibleObject(window, OBJID_CLIENT, accessible_iid, reinterpret_cast<void **>(&object));
|
||||
if (!timely) {
|
||||
if (object) object->Release();
|
||||
fail("unavailable"); return;
|
||||
}
|
||||
if (!response && GetWindow(window, GW_CHILD) &&
|
||||
(GetWindowLongPtrW(window, GWL_STYLE) & WS_CAPTION) == WS_CAPTION && inside(bounds, clip)) {
|
||||
// Preserve painted MDI titles only for the native fallback. A real
|
||||
// provider retains authority over its own names and protection state.
|
||||
ReviewNode title;
|
||||
title.id = id + "_caption"; title.parent = id; title.role = "window";
|
||||
title.name = caption(window); title.bounds = clipped;
|
||||
caption_reads.push_back({window, title.name, bounds});
|
||||
if (!add(std::move(title))) return;
|
||||
}
|
||||
if (SUCCEEDED(hr) && object) {
|
||||
VARIANT self{}; self.vt = VT_I4; self.lVal = CHILDID_SELF;
|
||||
accessible(object, self, id + "_a", id, clipped, depth + 1);
|
||||
object->Release();
|
||||
@@ -596,6 +696,19 @@ struct Capture {
|
||||
RECT client{}; GetClientRect(window, &client);
|
||||
POINT point{0,0}; ClientToScreen(window, &point); OffsetRect(&client, point.x, point.y);
|
||||
if (!IntersectRect(&client, &client, &clipped)) return;
|
||||
if (wine_access_class_matches(cls, L"MDIClient")) {
|
||||
DWORD_PTR answer = 0;
|
||||
if (!query(window, WM_MDIGETACTIVE, 0, 0, deadline, answer)) { fail("unavailable"); return; }
|
||||
HWND active = reinterpret_cast<HWND>(answer);
|
||||
RECT active_bounds{};
|
||||
if (active && (GetParent(active) != window || !GetWindowRect(active, &active_bounds))) { fail("stale"); return; }
|
||||
mdi_reads.push_back({window, active, active_bounds});
|
||||
// A maximized foreground document covers its MDI siblings even when
|
||||
// their native WS_VISIBLE flags remain set. Do not read those copies.
|
||||
if (active && IsWindowVisible(active) && inside(client, active_bounds)) {
|
||||
this->window(active, id, client, depth+1); return;
|
||||
}
|
||||
}
|
||||
for (HWND child = GetWindow(window, GW_CHILD); child && !failed; child = GetWindow(child, GW_HWNDNEXT))
|
||||
this->window(child, id, client, depth + 1);
|
||||
}
|
||||
@@ -672,7 +785,7 @@ void ReviewIdentityStore::retire() {
|
||||
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 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)) {
|
||||
@@ -698,21 +811,30 @@ ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocu
|
||||
// would miss an equal-length replacement and permit a mixed snapshot.
|
||||
for (const auto &read : capture.reads) {
|
||||
if (capture.failed) break;
|
||||
std::vector<WCHAR> current; bool limited = false;
|
||||
std::vector<WCHAR> current;
|
||||
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");
|
||||
// The first read already supplies a bounded capacity. Copy once, then
|
||||
// verify length, content and protection without another preliminary
|
||||
// length round trip (growth must still reject a truncated old prefix).
|
||||
current.assign(read.text.size()+1,0);
|
||||
DWORD_PTR copied=0,length=0;
|
||||
if(!query(read.window,WM_GETTEXT,current.size(),reinterpret_cast<LPARAM>(current.data()),deadline,copied) ||
|
||||
password(read.window) || !query(read.window,WM_GETTEXTLENGTH,0,0,deadline,length) ||
|
||||
password(read.window) || copied!=read.text.size() || length!=read.text.size())capture.fail("stale");
|
||||
current.resize(read.text.size());
|
||||
if(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");
|
||||
}
|
||||
std::vector<ReviewQuery> positions;
|
||||
for(const auto &[index,position]:read.positions)positions.push_back({EM_POSFROMCHAR,index});
|
||||
if(!capture.failed && !review_query_many(read.window,positions,deadline))capture.fail("stale");
|
||||
for(size_t i=0;!capture.failed && i<positions.size();++i)
|
||||
if(positions[i].answer!=read.positions[i].second)capture.fail("stale");
|
||||
}
|
||||
}
|
||||
for (const auto &read : capture.list_reads) {
|
||||
@@ -732,11 +854,11 @@ ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocu
|
||||
VariantClear(&state); capture.fail("stale"); break;
|
||||
}
|
||||
VariantClear(&state);
|
||||
auto matches = [&](bool value, const std::string &expected_text) {
|
||||
auto matches = [&](bool value, const std::vector<WCHAR> &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;
|
||||
(text ? std::equal(expected_text.begin(),expected_text.end(),text,text+SysStringLen(text)) : expected_text.empty());
|
||||
if (text) SysFreeString(text);
|
||||
return match;
|
||||
};
|
||||
@@ -746,6 +868,20 @@ ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocu
|
||||
x != read.bounds.left || y != read.bounds.top ||
|
||||
static_cast<int64_t>(x)+width != read.bounds.right || static_cast<int64_t>(y)+height != read.bounds.bottom) capture.fail("stale");
|
||||
}
|
||||
for(const auto &read:capture.mdi_reads) {
|
||||
if(capture.failed)break;
|
||||
DWORD_PTR active=0;RECT bounds{};
|
||||
if(!query(read.window,WM_MDIGETACTIVE,0,0,deadline,active) || reinterpret_cast<HWND>(active)!=read.active ||
|
||||
(read.active && (!GetWindowRect(read.active,&bounds) || !EqualRect(&bounds,&read.bounds))))capture.fail("stale");
|
||||
}
|
||||
for (const auto &read : capture.caption_reads) {
|
||||
if (capture.failed) break;
|
||||
RECT bounds{};
|
||||
if (!IsWindowVisible(read.window) || !GetWindowRect(read.window, &bounds) ||
|
||||
!EqualRect(&bounds, &read.bounds) ||
|
||||
(GetWindowLongPtrW(read.window, GWL_STYLE) & WS_CAPTION) != WS_CAPTION ||
|
||||
caption(read.window) != read.text) capture.fail("stale");
|
||||
}
|
||||
if (!capture.failed && !equal_focus(expected, store.observe_foreground())) capture.fail("stale");
|
||||
if (!capture.failed && physical_geometry) {
|
||||
POINT current_origin{};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
struct ReviewQuery {
|
||||
UINT message;
|
||||
WPARAM index;
|
||||
DWORD_PTR answer = 0;
|
||||
bool complete = false;
|
||||
};
|
||||
inline void CALLBACK review_query_completed(HWND, UINT, ULONG_PTR data, LRESULT result) {
|
||||
auto &reply = *reinterpret_cast<ReviewQuery *>(data);
|
||||
reply.answer = result;
|
||||
reply.complete = true;
|
||||
}
|
||||
|
||||
// Only scalar messages may use this path. The capture STA owns one batch;
|
||||
// late callbacks retain their storage until all replies arrive or it exits.
|
||||
inline bool review_query_many(HWND window, std::vector<ReviewQuery> &requests, uint64_t deadline) {
|
||||
static thread_local std::unique_ptr<std::vector<ReviewQuery>> pending;
|
||||
auto pump = [deadline] {
|
||||
MSG message{};
|
||||
while (GetTickCount64() < deadline && PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
};
|
||||
if (requests.empty()) return GetTickCount64() < deadline;
|
||||
auto complete = [](const auto &items) {
|
||||
return std::all_of(items.begin(), items.end(), [](const auto &item) { return item.complete; });
|
||||
};
|
||||
pump();
|
||||
if (GetTickCount64() >= deadline) return false;
|
||||
if (pending && !complete(*pending)) return false;
|
||||
pending = std::make_unique<std::vector<ReviewQuery>>(requests);
|
||||
for (auto &item : *pending) {
|
||||
if (!SendMessageCallbackW(window, item.message, item.index, 0, review_query_completed,
|
||||
reinterpret_cast<ULONG_PTR>(&item))) {
|
||||
// Keep any requests already sent alive, including during failure.
|
||||
item.complete = true;
|
||||
item.answer = static_cast<DWORD_PTR>(-1);
|
||||
}
|
||||
}
|
||||
while (!complete(*pending)) {
|
||||
auto now = GetTickCount64();
|
||||
if (now >= deadline) return false;
|
||||
MsgWaitForMultipleObjectsEx(0, nullptr, static_cast<DWORD>(deadline-now), QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
||||
pump();
|
||||
}
|
||||
if (GetTickCount64() >= deadline) return false;
|
||||
requests = *pending;
|
||||
pending.reset();
|
||||
return std::none_of(requests.begin(), requests.end(), [](const auto &item) {
|
||||
return item.answer == static_cast<DWORD_PTR>(-1);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,15 @@ 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.
|
||||
It also covers output serviced by a 16 ms message loop, late scalar replies,
|
||||
nested native containers, deeply clipped text, and maximized versus side-by-side
|
||||
MDI documents. Covered documents are excluded while visible titles remain
|
||||
available. These checks reproduce capture failures that can make review report
|
||||
that it cannot find the current location despite valid Wine focus.
|
||||
The padded MSAA status-name case checks that text after its terminating NUL
|
||||
does not reach D-Bus and that range lengths match the text actually sent.
|
||||
A local provider also checks that malformed UTF-16 padding cannot hide the
|
||||
valid label.
|
||||
|
||||
Run `python3 tests/review_integration.py --build-dir build` and its `--32-bit`
|
||||
variant sequentially. These exercise the exact wire types, actual-owner
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
#include "../review_capture.h"
|
||||
#define HAS_CAPTURE 1
|
||||
#endif
|
||||
static LRESULT CALLBACK padded_name_proc(HWND window,UINT message,WPARAM wp,LPARAM lp) {
|
||||
const WCHAR text[]={L'R',L'e',L'a',L'd',L'y',0,0xd800,0};
|
||||
if(message==WM_GETTEXTLENGTH)return 7;
|
||||
if(message==WM_GETTEXT && wp) {
|
||||
const size_t copied=std::min<size_t>(7,wp-1);
|
||||
std::copy_n(text,copied,reinterpret_cast<WCHAR*>(lp));
|
||||
reinterpret_cast<WCHAR*>(lp)[copied]=0;
|
||||
return copied;
|
||||
}
|
||||
return DefWindowProcW(window,message,wp,lp);
|
||||
}
|
||||
|
||||
static bool check(bool value, const char *message) {
|
||||
if (!value) std::fprintf(stderr, "FAIL: %s\n", message);
|
||||
@@ -67,15 +78,18 @@ int main(int argc, char **argv) {
|
||||
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}) {
|
||||
for (int mode : {1, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 17, 18, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33}) {
|
||||
std::fprintf(stderr, "capture test: mode %d\n", mode);
|
||||
SendMessageW(window, WM_APP + 10, mode, 0);
|
||||
auto started=GetTickCount64();
|
||||
auto result = capture();
|
||||
std::fprintf(stderr,"capture test: status=%s elapsed=%llu ms\n",result.status.c_str(),static_cast<unsigned long long>(GetTickCount64()-started));
|
||||
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 if (mode == 27) ok &= check(result.status != "ok" && result.nodes.empty() && GetTickCount64()-started<600,"delayed scalar batch 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");
|
||||
@@ -102,6 +116,37 @@ int main(int argc, char **argv) {
|
||||
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 == 26) {
|
||||
size_t visible_lines=0;
|
||||
for(const auto &node:result.nodes)for(const auto &range:node.ranges)
|
||||
if(range.text=="stable buffered output line")++visible_lines;
|
||||
ok &= check(visible_lines>=15,"frame-pumped multiline output captures within the deadline");
|
||||
}
|
||||
if(mode==27){
|
||||
Sleep(750);SendMessageW(window,WM_APP+10,0,0);
|
||||
auto recovered=capture();
|
||||
ok &= check(recovered.status=="ok" && joined(recovered).find("alpha beta")!=std::string::npos,"late scalar callbacks preserve storage and allow a fresh capture");
|
||||
}
|
||||
if(mode==28)ok &= check(value.find("top clipped visible line")!=std::string::npos,"top-clipped Edit captures its lower visible rows");
|
||||
if(mode==29)ok &= check(value.find("nested output ready")!=std::string::npos && SendMessageW(window,WM_APP+16,0,0),"native containers do not synthesize duplicate accessibility trees");
|
||||
if(mode==30)ok &= check(value.find("active mdi text")!=std::string::npos && value.find("covered mdi text")==std::string::npos,"maximized MDI child excludes covered background text");
|
||||
if(mode==31)ok &= check(value.find("active mdi text")!=std::string::npos && value.find("covered mdi text")!=std::string::npos,"side-by-side MDI children retain both visible documents");
|
||||
if (mode == 31) ok &= check(value.find("Active") != std::string::npos && value.find("Background") != std::string::npos,
|
||||
"side-by-side MDI window titles remain available for review");
|
||||
if (mode == 32) ok &= check(value.find("deep clipped visible line") != std::string::npos,
|
||||
"hundreds of clipped rows do not hide the visible tail");
|
||||
if (mode == 33) {
|
||||
bool safe=true, status_present=false;
|
||||
const auto status_id=store.identity(GetDlgItem(window,114))+"_a";
|
||||
for(const auto &node:result.nodes) {
|
||||
if(node.id==status_id)status_present=true;
|
||||
safe &= node.name.find('\0')==std::string::npos;
|
||||
for(const auto &range:node.ranges)safe &= range.text.find('\0')==std::string::npos;
|
||||
}
|
||||
ok &= check(status_present && safe,"padded MSAA status name produces D-Bus-safe accessibility text");
|
||||
ok &= check(value.find("Ready")!=std::string::npos,
|
||||
"status bar retains its exposed label");
|
||||
}
|
||||
if (mode == 21) {
|
||||
Sleep(750);
|
||||
ok &= check(SendMessageW(window,WM_APP + 15,0,0), "remote buffer lives until the delayed target returns");
|
||||
@@ -128,6 +173,23 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
if (mode != 4) ok &= check(SendMessageW(window, WM_APP + 11, 0, 0), "capture leaves caret and focus unchanged");
|
||||
}
|
||||
// A local provider avoids cross-process WM_GETTEXT marshalling, which
|
||||
// itself truncates at the first NUL. Its BSTR retains malformed padding.
|
||||
WNDCLASSW local{};local.hInstance=GetModuleHandleW(nullptr);
|
||||
local.lpfnWndProc=DefWindowProcW;local.lpszClassName=L"WineReviewLocalRoot";
|
||||
RegisterClassW(&local);
|
||||
HWND local_root=CreateWindowW(local.lpszClassName,L"Padded MSAA name",WS_OVERLAPPEDWINDOW,
|
||||
100,100,400,200,nullptr,nullptr,local.hInstance,nullptr);
|
||||
local.lpfnWndProc=padded_name_proc;local.lpszClassName=L"WineReviewPaddedName";
|
||||
RegisterClassW(&local);
|
||||
HWND padded=CreateWindowW(local.lpszClassName,L"",WS_CHILD|WS_VISIBLE,
|
||||
10,10,200,30,local_root,nullptr,local.hInstance,nullptr);
|
||||
ShowWindow(local_root,SW_SHOW);SetForegroundWindow(local_root);SetFocus(padded);
|
||||
auto padded_focus=store.observe_foreground();
|
||||
auto padded_capture=capture_review_window(store,padded_focus,GetTickCount64()+500);
|
||||
ok &= check(padded_capture.status=="ok" && joined(padded_capture).find("Ready")!=std::string::npos,
|
||||
"malformed UTF-16 padding does not discard the valid MSAA label");
|
||||
DestroyWindow(local_root);
|
||||
CoUninitialize();
|
||||
#endif
|
||||
PostMessageW(window, WM_CLOSE, 0, 0);
|
||||
|
||||
+100
-1
@@ -11,6 +11,7 @@
|
||||
#define REVIEW_PRIVACY_CHECK (WM_APP + 14)
|
||||
#define REVIEW_REMOTE_CHECK (WM_APP + 15)
|
||||
static HWND editor, output, password, hidden;
|
||||
static HWND mdi, mdi_active, mdi_background;
|
||||
static WCHAR *large;
|
||||
static WNDPROC original_edit;
|
||||
static BOOL replace_during_read;
|
||||
@@ -24,6 +25,27 @@ 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 BOOL frame_pump;
|
||||
static BOOL delay_batch;
|
||||
static unsigned int synthetic_containers;
|
||||
static LRESULT CALLBACK status_proc(HWND window,UINT message,WPARAM wp,LPARAM lp) {
|
||||
// Like MUSHclient's status bar, report a length beyond the first NUL.
|
||||
// The standard MSAA fallback retains that padding in its BSTR name.
|
||||
const WCHAR text[]=L"Ready\0Connected";
|
||||
if(message==WM_GETTEXTLENGTH)return (sizeof(text)/sizeof(*text))-1;
|
||||
if(message==WM_GETTEXT && wp) {
|
||||
size_t copied=(sizeof(text)/sizeof(*text))-1;
|
||||
if(copied>wp-1)copied=wp-1;
|
||||
memcpy((void*)lp,text,copied*sizeof(*text));
|
||||
((WCHAR*)lp)[copied]=0;
|
||||
return copied;
|
||||
}
|
||||
return DefWindowProcW(window,message,wp,lp);
|
||||
}
|
||||
static LRESULT CALLBACK container_proc(HWND window,UINT message,WPARAM wp,LPARAM lp) {
|
||||
if(message==WM_GETOBJECT && lp==OBJID_QUERYCLASSNAMEIDX){++synthetic_containers;Sleep(25);}
|
||||
return DefWindowProcW(window,message,wp,lp);
|
||||
}
|
||||
static LRESULT CALLBACK table_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if (message == LVM_GETITEMTEXTW && delay_table_read) {
|
||||
delay_table_read=FALSE;
|
||||
@@ -59,6 +81,7 @@ static LRESULT CALLBACK stall_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
return DefWindowProcW(window,message,wp,lp);
|
||||
}
|
||||
static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if(message==EM_LINEINDEX && delay_batch){delay_batch=FALSE;Sleep(650);}
|
||||
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;
|
||||
@@ -87,6 +110,12 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
return 0;
|
||||
}
|
||||
if (message == REVIEW_CONFIG) {
|
||||
frame_pump=FALSE;
|
||||
delay_batch=FALSE;
|
||||
synthetic_containers=0;
|
||||
ShowWindow(GetDlgItem(window,112),SW_HIDE);
|
||||
ShowWindow(GetDlgItem(window,114),SW_HIDE);
|
||||
ShowWindow(mdi,SW_HIDE);
|
||||
SendMessageW(editor,EM_SETPASSWORDCHAR,0,0);
|
||||
protect_during_length = replace_list_during_read = FALSE;
|
||||
replace_table_during_read=FALSE;
|
||||
@@ -105,6 +134,55 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
SendMessageW(editor, EM_SETSEL, 0, 0);
|
||||
MoveWindow(editor, 20, 35, 320, 90, TRUE);
|
||||
switch (wp) {
|
||||
case 33: ShowWindow(GetDlgItem(window,114),SW_SHOW); break;
|
||||
case 32: {
|
||||
WCHAR text[16384]; unsigned used = 0;
|
||||
for (unsigned row = 0; row < 400; ++row) {
|
||||
const WCHAR line[] = L"deep clipped visible line\r\n";
|
||||
for (unsigned j = 0; j < (sizeof(line)/sizeof(*line))-1; ++j) text[used++] = line[j];
|
||||
}
|
||||
text[used] = 0;
|
||||
SetWindowTextW(editor, text);
|
||||
MoveWindow(editor, 20, -5000, 320, 5100, TRUE);
|
||||
break;
|
||||
}
|
||||
case 30:
|
||||
ShowWindow(mdi,SW_SHOW);
|
||||
SendMessageW(mdi,WM_MDIACTIVATE,(WPARAM)mdi_active,0);
|
||||
SendMessageW(mdi,WM_MDIMAXIMIZE,(WPARAM)mdi_active,0);
|
||||
break;
|
||||
case 31:
|
||||
ShowWindow(mdi,SW_SHOW);
|
||||
SendMessageW(mdi,WM_MDIRESTORE,(WPARAM)mdi_active,0);
|
||||
SendMessageW(mdi,WM_MDIRESTORE,(WPARAM)mdi_background,0);
|
||||
MoveWindow(mdi_background,0,0,240,300,TRUE);
|
||||
MoveWindow(mdi_active,245,0,240,300,TRUE);
|
||||
SendMessageW(mdi,WM_MDIACTIVATE,(WPARAM)mdi_active,0);
|
||||
break;
|
||||
case 28: {
|
||||
WCHAR text[4096];unsigned used=0;
|
||||
for(unsigned row=0;row<80;++row) {
|
||||
const WCHAR line[]=L"top clipped visible line\r\n";
|
||||
for(unsigned j=0;j<(sizeof(line)/sizeof(*line))-1;++j)text[used++]=line[j];
|
||||
}
|
||||
text[used]=0;SetWindowTextW(editor,text);
|
||||
MoveWindow(editor,20,-250,320,380,TRUE);
|
||||
break;
|
||||
}
|
||||
case 29:ShowWindow(GetDlgItem(window,112),SW_SHOW);break;
|
||||
case 27:delay_batch=TRUE;break;
|
||||
case 26: {
|
||||
WCHAR text[4096];unsigned used=0;
|
||||
for(unsigned row=0;row<80;++row) {
|
||||
const WCHAR line[]=L"stable buffered output line\r\n";
|
||||
for(unsigned j=0;j<(sizeof(line)/sizeof(*line))-1;++j)text[used++]=line[j];
|
||||
}
|
||||
text[used]=0;SetWindowTextW(editor,text);
|
||||
MoveWindow(editor,20,35,320,380,TRUE);
|
||||
ShowWindow(output,SW_HIDE);ShowWindow(GetDlgItem(window,106),SW_HIDE);
|
||||
frame_pump=TRUE;
|
||||
break;
|
||||
}
|
||||
case 1: // surrogate before first displayed row, absolute offsets
|
||||
SetWindowTextW(editor, L"\U0001f600alpha\r\nbeta\r\nlast");
|
||||
SendMessageW(editor, EM_LINESCROLL, 0, 1);
|
||||
@@ -166,6 +244,7 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
||||
return 1;
|
||||
}
|
||||
if (message == REVIEW_PRIVACY_CHECK) return protected_text_reads == 0;
|
||||
if (message == WM_APP+16) return synthetic_containers==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;
|
||||
@@ -243,11 +322,31 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
|
||||
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);
|
||||
WNDCLASSW container={0};container.lpfnWndProc=container_proc;container.hInstance=instance;container.lpszClassName=L"WineAccessNativeContainer";
|
||||
RegisterClassW(&container);
|
||||
HWND parent=CreateWindowW(container.lpszClassName,L"",WS_CHILD,20,35,320,100,window,(HMENU)112,instance,NULL);
|
||||
for(unsigned level=0;level<8;++level)parent=CreateWindowW(container.lpszClassName,L"",WS_CHILD|WS_VISIBLE,0,0,320,100,parent,NULL,instance,NULL);
|
||||
CreateWindowW(L"Edit",L"nested output ready",WS_CHILD|WS_VISIBLE|ES_READONLY,0,0,320,80,parent,NULL,instance,NULL);
|
||||
WNDCLASSW mdi_child={0};mdi_child.lpfnWndProc=DefMDIChildProcW;mdi_child.hInstance=instance;mdi_child.lpszClassName=L"WineAccessMdiChild";
|
||||
RegisterClassW(&mdi_child);
|
||||
CLIENTCREATESTRUCT client_create={NULL,200};
|
||||
mdi=CreateWindowW(L"MDIClient",L"",WS_CHILD|WS_CLIPCHILDREN,20,35,500,350,window,(HMENU)113,instance,&client_create);
|
||||
mdi_background=CreateMDIWindowW(mdi_child.lpszClassName,L"Background",WS_OVERLAPPEDWINDOW|WS_VISIBLE,0,0,240,300,mdi,instance,0);
|
||||
CreateWindowW(L"Edit",L"covered mdi text",WS_CHILD|WS_VISIBLE|ES_READONLY,0,0,220,100,mdi_background,NULL,instance,NULL);
|
||||
mdi_active=CreateMDIWindowW(mdi_child.lpszClassName,L"Active",WS_OVERLAPPEDWINDOW|WS_VISIBLE,245,0,240,300,mdi,instance,0);
|
||||
CreateWindowW(L"Edit",L"active mdi text",WS_CHILD|WS_VISIBLE|ES_READONLY,0,0,220,100,mdi_active,NULL,instance,NULL);
|
||||
WNDCLASSW status=cls;status.lpszClassName=L"WineAccessStatusFixture";status.lpfnWndProc=status_proc;
|
||||
RegisterClassW(&status);
|
||||
CreateWindowW(status.lpszClassName,L"",WS_CHILD,20,420,480,20,window,(HMENU)114,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) {
|
||||
for (;;) {
|
||||
if (frame_pump) {
|
||||
if (!PeekMessageW(&message,NULL,0,0,PM_REMOVE)) { Sleep(16);continue; }
|
||||
if (message.message==WM_QUIT)break;
|
||||
} else if (GetMessageW(&message,NULL,0,0)<=0)break;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -115,6 +115,20 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
|
||||
assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0])
|
||||
other.close_sync(None)
|
||||
print('PASS: test_wrong_caller')
|
||||
configure(33)
|
||||
reply,_ = capture(); data=reply.unpack()[0]
|
||||
assert data['status']=='ok', data['status']
|
||||
ready=False
|
||||
for node in data['nodes']:
|
||||
assert '\0' not in node['name']
|
||||
for start,end,native_start,native_end,text,bounds in node['ranges']:
|
||||
assert '\0' not in text
|
||||
assert end-start==len(text)
|
||||
assert native_end-native_start==len(text.encode('utf-16-le'))//2
|
||||
if text=='Ready': ready=True
|
||||
assert ready, ('Padded MSAA status name lost its exposed text', data)
|
||||
subprocess.run(['wine',str(fixture),'--check'],env=env,check=True,timeout=5)
|
||||
print('PASS: test_padded_status_name_wire_lengths')
|
||||
configure(9)
|
||||
reply,_ = capture(); data=reply.unpack()[0]
|
||||
assert data['status'] in ('ok','stale')
|
||||
|
||||
Reference in new issue
Block a user