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