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

+3
View File
@@ -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
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{};
+60
View File
@@ -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);
});
}
+9
View File
@@ -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
+63 -1
View File
@@ -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
View File
@@ -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;
+14
View File
@@ -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')