Fix padded Wine review strings

This commit is contained in:
deedra committed 2026-10-11 22:07:14 -07:00
1 parent a8cb350467
commit f40e471938
7 files changed
+416 -33

No files matched your search

+167 -31
View File
@@ -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{};