Files
wine-access/review_capture.cpp
2026-10-11 22:07:14 -07:00

903 lines
45 KiB
C++

// SPDX-License-Identifier: LGPL-2.1-or-later
#include "review_capture.h"
#include "control_class.h"
#include "native_query.h"
#include "review_x11.h"
#include "review_query.h"
#include <commctrl.h>
#include <cstring>
#include <oleacc.h>
#include <algorithm>
#include <cstdio>
#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
// C++ compatible). GetUserObjectInformation returns these desktop flags.
constexpr DWORD wine_virtual_desktop = 0x80000000;
// Wine's import libraries do not export this IID as a Unix data symbol.
constexpr GUID accessible_iid{0x618736e0,0x3c3d,0x11cf,{0x81,0x0c,0x00,0xaa,0x00,0x38,0x9b,0x71}};
constexpr size_t max_source = 1000000, max_text = 1048576, max_nodes = 4096;
std::string utf8(const WCHAR *text, size_t length) {
if (!length) return {};
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text,
static_cast<int>(length), nullptr, 0, nullptr, nullptr);
if (!size) return {};
std::string value(size, '\0');
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text,
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();
return now < deadline && SendMessageTimeoutW(window, message, wp, lp,
SMTO_ABORTIFHUNG | SMTO_BLOCK, static_cast<UINT>(std::min<uint64_t>(100, deadline - now)), &result);
}
bool remote_query(HWND window, UINT message, WPARAM wp, LPARAM lp,
uint64_t deadline, DWORD_PTR &result) {
if (GetTickCount64() >= deadline) return false;
// Custom control messages are not marshalled. A timeout does not mean the
// target stopped dereferencing our remote buffer. Wait for real completion
// on this capture STA; the independent dispatcher expires the bus reply.
result=SendMessageW(window,message,wp,lp);
return GetTickCount64() < deadline;
}
bool password(HWND window);
bool read_text(HWND window, uint64_t deadline, size_t limit, std::vector<WCHAR> &text,
bool &limited) {
DWORD_PTR size = 0, copied = 0;
if (password(window) || !query(window, WM_GETTEXTLENGTH, 0, 0, deadline, size) || password(window)) return false;
if (size > limit) { limited = true; return false; }
text.assign(size + 1, 0);
if (!query(window, WM_GETTEXT, text.size(), reinterpret_cast<LPARAM>(text.data()), deadline, copied) || password(window)) { text.clear(); return false; }
// A grow/shrink between these messages is rejected, never truncated.
DWORD_PTR after = 0;
if (!query(window, WM_GETTEXTLENGTH, 0, 0, deadline, after) || copied != size || after != size) return false;
text.resize(size);
return true;
}
std::string caption(HWND window) {
// A foreign window's caption is held by USER32. Sending WM_GETTEXT here
// could deadlock against an editor hook currently sending WM_COPYDATA to
// the event thread, dropping ordinary focus feedback after its 50 ms wait.
int length=GetWindowTextLengthW(window);
if (length <= 0 || length > 4096) return {};
std::vector<WCHAR> value(length+1);
int copied=GetWindowTextW(window,value.data(),static_cast<int>(value.size()));
auto result = utf8(value.data(),copied);
return result.size() <= 4096 ? result : std::string{};
}
bool password(HWND window) {
WCHAR cls[128]{};
GetClassNameW(window, cls, 128);
return wine_access_class_matches(cls, L"Edit") && (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD);
}
bool equal_focus(const ReviewFocus &a, const ReviewFocus &b) {
return a.instance == b.instance && a.window == b.window && a.focus == b.focus &&
a.revision == b.revision && a.protected_text == b.protected_text &&
EqualRect(&a.bounds, &b.bounds);
}
std::string mnemonic(const std::string &value) {
std::string out;
for (size_t i = 0; i < value.size(); ++i) {
if (value[i] == '&') {
if (i + 1 < value.size() && value[i + 1] == '&') { out += '&'; ++i; }
} else out += value[i];
}
return out;
}
bool inside(const RECT &child, const RECT &parent) {
return child.right > child.left && child.bottom > child.top &&
child.left >= parent.left && child.top >= parent.top &&
child.right <= parent.right && child.bottom <= parent.bottom;
}
struct EditLayout {
RECT format{};
DWORD_PTR first=0, lines=0, font=0;
LONG_PTR style=0;
int horizontal=0, vertical=0;
};
bool edit_layout(HWND window, uint64_t deadline, EditLayout &layout) {
DWORD_PTR answer=0;
layout.style=GetWindowLongPtrW(window,GWL_STYLE);
layout.horizontal=GetScrollPos(window,SB_HORZ);
layout.vertical=GetScrollPos(window,SB_VERT);
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) {
return EqualRect(&a.format,&b.format) && a.first == b.first && a.lines == b.lines &&
a.font == b.font && a.style == b.style && a.horizontal == b.horizontal && a.vertical == b.vertical;
}
struct SourceRead {
HWND window; std::vector<WCHAR> text; RECT bounds;
std::optional<EditLayout> layout{};
std::vector<std::pair<size_t,DWORD_PTR>> positions{};
};
struct AccessibleRead {
IAccessible *object;
LONG child;
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;
};
struct ReviewItem64 {
uint32_t mask; int32_t item, subitem; uint32_t state, state_mask, padding;
uint64_t text; int32_t capacity; uint32_t padding2;
};
static_assert(offsetof(ReviewItem32,text) == 20);
static_assert(offsetof(ReviewItem64,text) == 24);
struct Capture {
ReviewIdentityStore &store;
ReviewCapture result;
uint64_t deadline;
size_t source_units = 0, text_bytes = 0;
size_t serialized_estimate = 512;
std::vector<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;
~Capture() {
for (auto &read : accessible_reads) read.object->Release();
}
void fail(const char *status) { failed = true; result.status = status; }
bool add(ReviewNode node) {
if (GetTickCount64() >= deadline) { fail("unavailable"); return false; }
if (result.nodes.size() >= max_nodes || node.name.size() > 4096 || node.id.size() > 256) {
fail("limited"); return false;
}
text_bytes += node.name.size();
serialized_estimate += 512 + node.id.size() + node.parent.size() + node.role.size() + node.name.size();
for (const auto &range : node.ranges) {
text_bytes += range.text.size(); serialized_estimate += 128 + range.text.size();
}
// Reserve ample metadata space within the 2 MiB serialized bound.
if (text_bytes > max_text || serialized_estimate > 1800000) {
fail("limited"); return false;
}
result.nodes.push_back(std::move(node));
return true;
}
bool edit(HWND window, ReviewNode &node, const RECT &clip) {
// Exclude protected text before WM_GETTEXT or any provider value call.
if (node.protected_text) return true;
std::vector<WCHAR> text;
bool limited = false;
if (!read_text(window, deadline, max_source - source_units, text, limited)) {
fail(limited ? "limited" : "stale"); return false;
}
source_units += text.size();
EditLayout layout{};
if (!edit_layout(window,deadline,layout)) { fail("unavailable"); return false; }
std::vector<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;
};
// Map every native position, including invisible prefixes. Interior
// surrogate boundaries stay invalid rather than splitting a character.
std::vector<uint64_t> offsets(text.size() + 1, UINT64_MAX);
uint64_t runes = 0;
for (size_t i = 0; i < text.size();) {
offsets[i] = runes++;
if (text[i] >= 0xd800 && text[i] <= 0xdbff) {
if (i + 1 >= text.size() || text[i + 1] < 0xdc00 || text[i + 1] > 0xdfff) {
fail("unavailable"); return false;
}
i += 2;
} else {
if (text[i] >= 0xdc00 && text[i] <= 0xdfff) { fail("unavailable"); return false; }
++i;
}
}
offsets.back() = runes;
node.text_known = true;
DWORD start = 0, end = 0; DWORD_PTR answer = 0;
if (query(window, EM_GETSEL, reinterpret_cast<WPARAM>(&start), reinterpret_cast<LPARAM>(&end), deadline, answer) &&
end < offsets.size() && offsets[end] != UINT64_MAX) {
node.caret_known = true; node.caret_utf16 = end; node.caret = offsets[end];
}
RECT format=layout.format;
POINT origin{0, 0}; ClientToScreen(window, &origin);
RECT client{}; GetClientRect(window, &client); OffsetRect(&client, origin.x, origin.y);
RECT visible{};
RECT original_bounds{}; GetWindowRect(window, &original_bounds);
if (!IntersectRect(&visible, &client, &clip)) {
reads.push_back({window,std::move(text),original_bounds,layout,{}}); return true;
}
HDC dc = GetDC(window);
if (!dc) { fail("unavailable"); return false; }
DWORD_PTR font = layout.font;
HGDIOBJ old = font ? SelectObject(dc, reinterpret_cast<HFONT>(font)) : nullptr;
TEXTMETRICW metrics{}; GetTextMetricsW(dc, &metrics);
DWORD_PTR lines=layout.lines, first=layout.first;
bool good=true;
const bool multiline = GetWindowLongPtrW(window, GWL_STYLE) & ES_MULTILINE;
if (!multiline) first = 0; // Single-line message returns character index.
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-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.
if (index < text.size()) {
good=position_at(index,position);
if (!good) { fail("unavailable"); break; }
} else {
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;
if (y >= visible.bottom) break;
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;
if (offsets[last] == UINT64_MAX) --last;
DWORD_PTR last_position=0;
if (!position_at(last,last_position)) { good=false; break; }
SIZE last_extent{};
GetTextExtentPoint32W(dc,text.data()+last,static_cast<int>(finish-last),&last_extent);
LONG last_y=static_cast<short>(HIWORD(last_position))+origin.y;
if (last_y != y) { fail("stale"); break; }
bounds.right=static_cast<short>(LOWORD(last_position))+origin.x+std::max<LONG>(1,last_extent.cx);
}
if (length && !inside(bounds,visible)) {
// Read only fully displayed characters. Native positions locate the
// visible substring without scrolling or changing the selection;
// binary searches skip potentially huge horizontally hidden prefixes.
auto next = [&](size_t at) { return at+((text[at]>=0xd800 && text[at]<=0xdbff) ? 2 : 1); };
auto previous = [&](size_t at) {
--at; if (text[at]>=0xdc00 && text[at]<=0xdfff && at>index) --at; return at;
};
auto character_bounds = [&](size_t at, RECT &character) {
DWORD_PTR start_position=0, next_position=0;
if (!position_at(at,start_position)) return false;
LONG left=static_cast<short>(LOWORD(start_position))+origin.x;
LONG top=static_cast<short>(HIWORD(start_position))+origin.y;
SIZE size{};
GetTextExtentPoint32W(dc,text.data()+at,static_cast<int>(next(at)-at),&size);
LONG right=left+std::max<LONG>(1,size.cx);
if (next(at)<finish) {
if (!position_at(next(at),next_position)) return false;
LONG next_y=static_cast<short>(HIWORD(next_position))+origin.y;
LONG next_x=static_cast<short>(LOWORD(next_position))+origin.x;
if (next_y == top && next_x > left) right=next_x;
}
character={left,top,right,top+metrics.tmHeight};
return true;
};
RECT first_bounds{};
size_t begin=index, stop=finish;
if (!multiline) begin=std::min<size_t>(finish,std::max<size_t>(index,layout.first));
if (begin<stop && offsets[begin] == UINT64_MAX) ++begin;
if (begin<stop && !character_bounds(begin,first_bounds)) { good=false; break; }
if (begin<stop && first_bounds.left < visible.left) {
size_t low=begin, high=stop;
while (low<high) {
size_t mid=low+(high-low)/2;
if (offsets[mid] == UINT64_MAX) --mid;
RECT character{};
if (!character_bounds(mid,character)) { good=false; break; }
if (character.left < visible.left) low=next(mid); else high=mid;
}
if (!good) break;
begin=low;
}
if (begin == stop) continue;
RECT last_bounds{};
if (!character_bounds(previous(stop),last_bounds)) { good=false; break; }
if (last_bounds.right > visible.right) {
size_t low=begin, high=stop;
while (low<high) {
size_t mid=low+(high-low)/2;
if (offsets[mid] == UINT64_MAX) --mid;
RECT character{};
if (!character_bounds(mid,character)) { good=false; break; }
if (character.right <= visible.right) low=next(mid); else high=mid;
}
if (!good) break;
stop=low;
}
if (begin == stop) continue;
if (!character_bounds(begin,first_bounds) || !character_bounds(previous(stop),last_bounds)) { good=false; break; }
bounds={first_bounds.left,first_bounds.top,last_bounds.right,last_bounds.bottom};
if (!inside(bounds,visible)) continue;
index=begin; finish=stop;
}
if (!inside(bounds,visible)) continue;
// Keep only the actual LF of a blank CRLF row, with absolute offsets.
if (!length && index < text.size()) {
if (text[index] == '\r' && index + 1 < text.size() && text[index + 1] == '\n') ++index;
if (text[index] == '\n') finish = index + 1;
}
if (finish == index) continue;
if (offsets[index] == UINT64_MAX || offsets[finish] == UINT64_MAX) { fail("unavailable"); break; }
size_t count = offsets[finish] - offsets[index];
if (count > 4096 || captured + count > 65536 || node.ranges.size() >= 256) { fail("limited"); break; }
node.ranges.push_back({offsets[index], offsets[finish], index, finish,
utf8(text.data() + index, finish - index), bounds});
captured += count;
}
if (old) SelectObject(dc, old);
ReleaseDC(window, dc);
if (!good && !failed) fail("unavailable");
reads.push_back({window,std::move(text),original_bounds,layout,std::move(positions)});
return !failed;
}
bool list_item(HWND window, size_t index, std::vector<WCHAR> &text, RECT &bounds) {
DWORD_PTR length=0, result=0;
if (!query(window, LB_GETTEXTLEN, index, 0, deadline, length) || static_cast<LONG_PTR>(length) < 0) return false;
if (length > 4096) { fail("limited"); return false; }
text.assign(length+1,0);
if (!query(window, LB_GETTEXT, index, reinterpret_cast<LPARAM>(text.data()), deadline, result) || result != length ||
!query(window, LB_GETITEMRECT, index, reinterpret_cast<LPARAM>(&bounds), deadline, result) ||
static_cast<LONG_PTR>(result) == LB_ERR) return false;
text.resize(length);
POINT point{0,0}; ClientToScreen(window,&point); OffsetRect(&bounds,point.x,point.y);
return true;
}
void list(HWND window, const std::string &parent, const RECT &clip) {
LONG_PTR style=GetWindowLongPtrW(window,GWL_STYLE);
if ((style & (LBS_OWNERDRAWFIXED|LBS_OWNERDRAWVARIABLE)) && !(style & LBS_HASSTRINGS)) return;
DWORD_PTR count=0, first=0;
if (!query(window,LB_GETCOUNT,0,0,deadline,count) || static_cast<LONG_PTR>(count) < 0 ||
!query(window,LB_GETTOPINDEX,0,0,deadline,first)) { fail("unavailable"); return; }
for (size_t i=first; i<count && !failed; ++i) {
std::vector<WCHAR> text; RECT bounds{};
if (!list_item(window,i,text,bounds)) { if (!failed) fail("stale"); break; }
if (bounds.top >= clip.bottom) break;
if (!inside(bounds,clip)) continue;
auto value=utf8(text.data(),text.size());
ReviewNode node;
node.id=parent+"_l"+std::to_string(i); node.parent=parent; node.role="list item";
node.bounds=bounds; node.name=value; node.text_known=true;
uint64_t runes=0; for (unsigned char c:value) if ((c&0xc0)!=0x80) ++runes;
if (!value.empty()) node.ranges.push_back({0,runes,0,text.size(),value,bounds});
if (!add(std::move(node))) break;
list_reads.push_back({window,i,std::move(text),bounds});
}
}
bool table_item(HWND window, size_t index, int column, std::vector<WCHAR> &text, RECT &bounds) {
// Common-control messages contain pointers and require memory in the
// target process, including a target-width LVITEM for 32-bit clients.
constexpr size_t offset=128, capacity=4098, bytes=offset+capacity*sizeof(WCHAR);
native_control::RemoteBuffer remote(window,bytes);
if (!remote) return false;
std::vector<BYTE> buffer(bytes,0);
if (remote.is32Bit()) {
ReviewItem32 item{LVIF_TEXT,static_cast<int32_t>(index),column,0,0,
static_cast<uint32_t>(remote.address(offset)),capacity};
std::memcpy(buffer.data(),&item,sizeof(item));
} else {
ReviewItem64 item{LVIF_TEXT,static_cast<int32_t>(index),column,0,0,0,
remote.address(offset),capacity,0};
std::memcpy(buffer.data(),&item,sizeof(item));
}
DWORD_PTR copied=0, answer=0;
if (!remote.write(buffer.data(),bytes) ||
!remote_query(window,LVM_GETITEMTEXTW,index,remote.address(),deadline,copied)) return false;
if (copied >= capacity-1 || copied > max_source-source_units) { fail("limited"); return false; }
source_units+=copied;
text.assign(copied+1,0);
if (!remote.read(text.data(),text.size()*sizeof(WCHAR),offset) || text.back() != 0) return false;
text.resize(copied);
bool report=(GetWindowLongPtrW(window,GWL_STYLE)&LVS_TYPEMASK) == LVS_REPORT;
bounds={LVIR_LABEL,column,0,0};
if (!remote.write(&bounds,sizeof(bounds)) ||
!remote_query(window,report ? LVM_GETSUBITEMRECT : LVM_GETITEMRECT,index,remote.address(),deadline,answer) || !answer ||
!remote.read(&bounds,sizeof(bounds))) return false;
// Column zero's rectangle includes all report columns on some Wine
// versions. Restrict it to the actual header cell, never a guessed width.
DWORD_PTR header=0;
if (report &&
query(window,LVM_GETHEADER,0,0,deadline,header) && header) {
RECT cell{};
if (!remote.write(&cell,sizeof(cell)) ||
!remote_query(reinterpret_cast<HWND>(header),HDM_GETITEMRECT,column,remote.address(),deadline,answer) || !answer ||
!remote.read(&cell,sizeof(cell))) return false;
POINT origin{0,0}; ClientToScreen(reinterpret_cast<HWND>(header),&origin);
POINT table_origin{0,0}; ClientToScreen(window,&table_origin);
bounds.left=std::max(bounds.left,cell.left+origin.x-table_origin.x);
bounds.right=std::min(bounds.right,cell.right+origin.x-table_origin.x);
}
POINT origin{0,0}; ClientToScreen(window,&origin); OffsetRect(&bounds,origin.x,origin.y);
return true;
}
void table(HWND window, const std::string &parent, const RECT &clip) {
DWORD_PTR count=0, first=0, header=0, columns=1;
if (!query(window,LVM_GETITEMCOUNT,0,0,deadline,count) ||
!query(window,LVM_GETTOPINDEX,0,0,deadline,first)) { fail("unavailable"); return; }
if ((GetWindowLongPtrW(window,GWL_STYLE)&LVS_TYPEMASK) == LVS_REPORT &&
query(window,LVM_GETHEADER,0,0,deadline,header) && header &&
!query(reinterpret_cast<HWND>(header),HDM_GETITEMCOUNT,0,0,deadline,columns)) { fail("unavailable"); return; }
if (columns > 256 || count > INT32_MAX) { fail("limited"); return; }
for (size_t row=first; row<count && !failed; ++row) {
bool below=false;
for (DWORD_PTR column=0; column<columns && !failed; ++column) {
std::vector<WCHAR> text; RECT bounds{};
if (!table_item(window,row,column,text,bounds)) { if (!failed) fail("stale"); break; }
if (bounds.top >= clip.bottom) { below=true; break; }
if (!inside(bounds,clip)) continue;
auto value=utf8(text.data(),text.size());
if (!text.empty() && value.empty()) { fail("unavailable"); break; }
ReviewNode node;
node.id=parent+"_r"+std::to_string(row)+"_c"+std::to_string(column);
node.parent=parent; node.role="table cell"; node.bounds=bounds; node.name=value;
node.text_known=true;
uint64_t runes=0; for (unsigned char c:value) if ((c&0xc0)!=0x80) ++runes;
if (!value.empty()) node.ranges.push_back({0,runes,0,text.size(),value,bounds});
if (!add(std::move(node))) break;
list_reads.push_back({window,row,std::move(text),bounds,static_cast<int>(column)});
}
if (below) break;
}
}
void accessible(IAccessible *object, VARIANT child, const std::string &id,
const std::string &parent, const RECT &clip, unsigned depth) {
if (failed) return;
if (GetTickCount64() >= deadline) { fail("unavailable"); return; }
if (++visited > max_nodes || depth > 64) { fail("limited"); return; }
VARIANT state{};
if (FAILED(object->get_accState(child, &state))) return;
if (state.vt != VT_I4) { VariantClear(&state); return; }
const LONG flags = state.lVal; VariantClear(&state);
if (flags & (STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN)) return;
ReviewNode node;
node.id = id; node.parent = parent;
node.protected_text = (flags & STATE_SYSTEM_PROTECTED) != 0;
LONG x=0, y=0, width=0, height=0;
if (FAILED(object->accLocation(&x, &y, &width, &height, child)) || width <= 0 || height <= 0 ||
static_cast<int64_t>(x)+width > INT32_MAX || static_cast<int64_t>(y)+height > INT32_MAX) return;
RECT bounds{x,y,x+width,y+height};
if (!IntersectRect(&node.bounds, &bounds, &clip)) return;
VARIANT role{};
object->get_accRole(child, &role);
LONG role_id = role.vt == VT_I4 ? role.lVal : 0;
WCHAR role_name[128]{};
if (role_id) GetRoleTextW(role_id, role_name, 128);
node.role = node.protected_text ? "password text" : utf8(role_name, lstrlenW(role_name));
if (node.role.empty()) node.role = "panel";
VariantClear(&role);
// Do not ask a protected provider for either its name or its value.
if (!node.protected_text) {
BSTR name = nullptr, value = nullptr;
std::vector<WCHAR> raw_name, raw_value;
object->get_accName(child, &name);
if (name) {
if (SysStringLen(name) > 4096) fail("limited");
else {
raw_name.assign(name,name+SysStringLen(name));
node.name = accessible_text(name,SysStringLen(name));
}
SysFreeString(name);
}
std::string text = node.name;
if (role_id == ROLE_SYSTEM_TEXT || role_id == ROLE_SYSTEM_COMBOBOX) {
object->get_accValue(child, &value);
if (value) {
if (SysStringLen(value) > 4096) fail("limited");
else {
raw_value.assign(value,value+SysStringLen(value));
text = accessible_text(value,SysStringLen(value));
}
SysFreeString(value);
}
}
if (!failed) {
object->AddRef();
accessible_reads.push_back({object, child.lVal, raw_name, raw_value,
role_id == ROLE_SYSTEM_TEXT || role_id == ROLE_SYSTEM_COMBOBOX, bounds});
}
if (role_id == ROLE_SYSTEM_PUSHBUTTON || role_id == ROLE_SYSTEM_STATICTEXT) {
node.name = mnemonic(node.name); text = mnemonic(text);
}
// Without per-line MSAA geometry, only a single exposed caption/value
// can be represented faithfully. Never flatten a multi-line provider.
bool caption_only=role_id == ROLE_SYSTEM_PUSHBUTTON || role_id == ROLE_SYSTEM_STATICTEXT;
if (caption_only && !inside(bounds,clip)) node.name.clear();
if (!caption_only && !failed && !text.empty() && text.find_first_of("\r\n") == std::string::npos && inside(bounds, clip)) {
uint64_t runes=0, units=0;
for (unsigned char c : text) {
if ((c & 0xc0) != 0x80) { ++runes; units += c >= 0xf0 ? 2 : 1; }
}
node.text_known = true;
node.ranges.push_back({0,runes,0,units,text,bounds});
}
}
const RECT child_clip=node.bounds;
if (failed || !add(std::move(node))) return;
if (child.vt != VT_I4 || child.lVal != CHILDID_SELF) return;
LONG count = 0;
if (FAILED(object->get_accChildCount(&count)) || count <= 0) return;
if (count > static_cast<LONG>(max_nodes)) { fail("limited"); return; }
std::vector<VARIANT> children(count);
LONG obtained = 0;
if (FAILED(AccessibleChildren(object, 0, count, children.data(), &obtained))) return;
for (LONG i=0; i<obtained; ++i) {
auto &item = children[i];
const auto child_id = id + "_" + std::to_string(i+1);
if (item.vt == VT_DISPATCH && item.pdispVal) {
IAccessible *nested=nullptr;
if (SUCCEEDED(item.pdispVal->QueryInterface(accessible_iid, reinterpret_cast<void**>(&nested)))) {
VARIANT self{}; self.vt=VT_I4; self.lVal=CHILDID_SELF;
accessible(nested, self, child_id, id, child_clip, depth+1);
nested->Release();
}
} else if (item.vt == VT_I4) accessible(object, item, child_id, id, child_clip, depth+1);
VariantClear(&item);
}
}
void window(HWND window, const std::string &parent, RECT clip, unsigned depth) {
if (failed || !IsWindowVisible(window)) return;
if (depth > 64) { fail("limited"); return; }
RECT bounds{}, clipped{};
if (!GetWindowRect(window, &bounds) || !IntersectRect(&clipped, &bounds, &clip)) return;
native.insert(window);
ReviewNode node;
node.id = store.identity(window); node.parent = parent; node.bounds = clipped;
WCHAR cls[128]{}; GetClassNameW(window, cls, 128);
node.protected_text = password(window);
bool is_edit = wine_access_class_matches(cls, L"Edit");
bool button = wine_access_class_matches(cls, L"Button");
bool label = wine_access_class_matches(cls, L"Static");
bool list_box = wine_access_class_matches(cls, L"ListBox");
bool list_view = wine_access_class_matches(cls, L"SysListView32");
node.role = is_edit ? (node.protected_text ? "password text" : "text") :
button ? "push button" : label ? "label" : "panel";
if (is_edit) {
if (!edit(window, node, clipped)) return;
} else if (button || label) {
std::vector<WCHAR> text; bool limited = false;
if (!read_text(window, deadline, 4096, text, limited)) { fail(limited ? "limited" : "unavailable"); return; }
auto value=utf8(text.data(),text.size());
node.name = label && (GetWindowLongPtrW(window,GWL_STYLE)&SS_NOPREFIX) ? value : mnemonic(value);
// Captions have no text-position API. Existing flat review represents
// their decoded accessible name using the control's native bounds;
// keep offsets unknown instead of inventing Edit-style UTF-16 ranges.
if (!inside(bounds,clip)) node.name.clear();
reads.push_back({window, std::move(text), bounds});
}
const auto id = node.id;
if (!add(std::move(node))) return;
RECT item_clip{}; GetClientRect(window,&item_clip);
POINT item_origin{0,0}; ClientToScreen(window,&item_origin); OffsetRect(&item_clip,item_origin.x,item_origin.y);
if (IntersectRect(&item_clip,&item_clip,&clipped)) {
if (list_box) list(window,id,item_clip);
if (list_view) table(window,id,item_clip);
}
if (!is_edit && !button && !label && !list_box && !list_view) {
IAccessible *object = nullptr;
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();
}
}
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);
}
};
} // namespace
ReviewIdentityStore::ReviewIdentityStore() : state_(std::make_shared<State>()) {
GUID guid{}; CoCreateGuid(&guid);
char value[80];
std::snprintf(value, sizeof(value), "%08lx-%04x-%04x-%02x%02x%02x%02x%02x%02x%02x%02x",
static_cast<unsigned long>(guid.Data1), guid.Data2, guid.Data3,
guid.Data4[0], guid.Data4[1], guid.Data4[2], guid.Data4[3], guid.Data4[4], guid.Data4[5], guid.Data4[6], guid.Data4[7]);
state_->instance = value;
}
std::string ReviewIdentityStore::identity(HWND window) {
std::lock_guard<std::mutex> lock(state_->mutex);
auto &value = state_->identities[window];
if (value.empty()) {
char token[40];
std::snprintf(token,sizeof(token),"w%016llx_%016llx",
static_cast<unsigned long long>(reinterpret_cast<uintptr_t>(window)),
static_cast<unsigned long long>(++state_->next));
value=token;
}
return value;
}
ReviewFocus ReviewIdentityStore::observe_foreground() {
ReviewFocus notice;
HWND window = GetForegroundWindow();
if (!window) return notice;
DWORD thread = GetWindowThreadProcessId(window, nullptr);
GUITHREADINFO gui{}; gui.cbSize = sizeof(gui);
HWND focus = GetGUIThreadInfo(thread, &gui) && gui.hwndFocus ? gui.hwndFocus : window;
if (GetAncestor(focus, GA_ROOT) != window) return notice;
notice.instance = state_->instance; notice.window = identity(window); notice.focus = identity(focus);
WCHAR cls[128]{}; GetClassNameW(window,cls,128);
notice.title = caption(window); notice.role = lstrcmpW(cls,L"#32770")==0 ? "dialog" : "window";
notice.protected_text = password(focus);
// X11 exposes Wine's application client, not its simulated Win32 border.
// Use that same native client rectangle as the root of visible review.
GetClientRect(window, &notice.bounds);
POINT origin{0,0}; ClientToScreen(window,&origin);
OffsetRect(&notice.bounds,origin.x,origin.y);
// Wine's desktop flags distinguish its real X11 root from a virtual
// desktop, whose children include the simulated Win32 window border.
USEROBJECTFLAGS desktop_flags{};
if (GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()),UOI_FLAGS,
&desktop_flags,sizeof(desktop_flags),nullptr) &&
(desktop_flags.dwFlags & wine_virtual_desktop))
GetWindowRect(window,&notice.bounds);
RECT actual{}; POINT native_origin{};
if (review_x11_geometry(window,actual,native_origin)) notice.bounds=actual;
{
std::lock_guard<std::mutex> lock(state_->mutex);
if (state_->window != notice.window || state_->focus != notice.focus) {
++state_->revision; state_->window = notice.window; state_->focus = notice.focus;
}
notice.revision = state_->revision;
}
return notice;
}
void ReviewIdentityStore::destroyed(HWND window, LONG object, LONG child) {
if (object == OBJID_CARET || object == OBJID_CURSOR) return;
if (object == OBJID_WINDOW && child != CHILDID_SELF) return;
std::lock_guard<std::mutex> lock(state_->mutex);
auto found = state_->identities.find(window);
if (found == state_->identities.end()) return;
if (found->second == state_->focus || found->second == state_->window) ++state_->revision;
state_->identities.erase(found);
}
void ReviewIdentityStore::reordered(HWND window, LONG object) { destroyed(window, object, 0); }
void ReviewIdentityStore::retire() {
std::lock_guard<std::mutex> lock(state_->mutex);
state_->identities.clear(); state_->window.clear(); state_->focus.clear(); ++state_->revision;
}
ReviewCapture capture_review_window(ReviewIdentityStore &store, const ReviewFocus &expected, uint64_t deadline) {
Capture capture{store, {}, deadline, 0, 0, 512, {}, {}, {}, {}, {}, {}, 0, false};
capture.result.source = expected;
auto initial = store.observe_foreground();
if (expected.window.empty() || !equal_focus(expected, initial)) {
capture.result.status = "stale"; return capture.result;
}
HWND window = GetForegroundWindow();
RECT actual{}; POINT native_origin{};
const bool physical_geometry=review_x11_geometry(window,actual,native_origin);
if (physical_geometry && !EqualRect(&actual,&expected.bounds)) {
capture.result.status="stale"; return capture.result;
}
ReviewNode root;
root.id = expected.window; root.role = expected.role; root.name = expected.title; root.bounds = expected.bounds;
capture.add(std::move(root));
RECT client{}; GetClientRect(window, &client);
POINT point{0,0}; ClientToScreen(window, &point); OffsetRect(&client, point.x, point.y);
RECT physical_clip=expected.bounds;
OffsetRect(&physical_clip,-native_origin.x,-native_origin.y);
IntersectRect(&client,&client,&physical_clip);
for (HWND child = GetWindow(window, GW_CHILD); child && !capture.failed; child = GetWindow(child, GW_HWNDNEXT))
capture.window(child, expected.window, client, 1);
// Reread the entire bounded Edit/caption source: checking only its length
// would miss an equal-length replacement and permit a mixed snapshot.
for (const auto &read : capture.reads) {
if (capture.failed) break;
std::vector<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();
// 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");
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) {
if (capture.failed) break;
std::vector<WCHAR> text; RECT bounds{};
bool read_ok=read.column < 0 ? capture.list_item(read.window,read.index,text,bounds) :
capture.table_item(read.window,read.index,read.column,text,bounds);
if (!IsWindowVisible(read.window) || !read_ok ||
text != read.text || !EqualRect(&bounds,&read.bounds)) capture.fail("stale");
}
for (const auto &read : capture.accessible_reads) {
if (capture.failed) break;
if (GetTickCount64() >= deadline) { capture.fail("unavailable"); break; }
VARIANT child{}, state{}; child.vt = VT_I4; child.lVal = read.child;
if (FAILED(read.object->get_accState(child, &state)) || state.vt != VT_I4 ||
(state.lVal & (STATE_SYSTEM_PROTECTED | STATE_SYSTEM_INVISIBLE | STATE_SYSTEM_OFFSCREEN))) {
VariantClear(&state); capture.fail("stale"); break;
}
VariantClear(&state);
auto matches = [&](bool value, const std::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 ? std::equal(expected_text.begin(),expected_text.end(),text,text+SysStringLen(text)) : expected_text.empty());
if (text) SysFreeString(text);
return match;
};
if (!matches(false, read.name) || (read.read_value && !matches(true, read.value))) capture.fail("stale");
LONG x=0,y=0,width=0,height=0;
if (FAILED(read.object->accLocation(&x,&y,&width,&height,child)) ||
x != read.bounds.left || y != read.bounds.top ||
static_cast<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{};
if (!review_x11_geometry(window,actual,current_origin) || !EqualRect(&actual,&expected.bounds) ||
current_origin.x != native_origin.x || current_origin.y != native_origin.y) capture.fail("stale");
}
if (!capture.failed && GetTickCount64() >= deadline) capture.fail("unavailable");
if (capture.failed) capture.result.nodes.clear();
else {
for (size_t i=1;i<capture.result.nodes.size();++i) {
auto &node=capture.result.nodes[i];
OffsetRect(&node.bounds,native_origin.x,native_origin.y);
for (auto &range:node.ranges) OffsetRect(&range.bounds,native_origin.x,native_origin.y);
}
capture.result.status = "ok";
}
return std::move(capture.result);
}