Provide fresh Wine review snapshots and 32-bit Edit hooks

Expose visible Wine window text to the matching Go reader's independent
keypad review commands, and extend standard Edit feedback to 32-bit programs
such as MUSHclient without requiring 32-bit Linux GLib/Winelib libraries.

Add lifetime-aware instance/window/control identities and bounded capture
of standard Edit text, button/static captions, native ListBox/ListView items,
and usable MSAA text. Preserve focus, caret, selection, scrolling, and the
clipboard. Exclude hidden/password content before retrieval, retain both
rune and UTF-16 offsets, clip visible ranges, and reject changing or
oversized sources rather than returning incomplete text as a full snapshot.

Export CaptureWindow on the version-1 WineReview D-Bus interface. Register
with the current reader service owner and publish copied focus metadata.
Authorize capture only for the current same-user owner. Use a dedicated
GLib dispatcher and separate COM worker with a 500 ms deadline, one active
job and one pending job, bounded payloads, and explicit stale/busy/limited/
unavailable replies. Keep menu and focus forwarding responsive when an MSAA
provider stalls; retain ordinary feedback with older readers.

Read the actual X11 geometry associated with Wine HWNDs using bounded XCB
queries and __wine_x11_whole_window. Translate virtual-desktop coordinates
and recheck physical geometry before returning captured text. Link XCB in
the helper/tests and declare libxcb in the Arch package dependencies.

Build edit_hook.c into the 32-bit DLL and attach both menu and input hooks
to the foreground 32-bit UI thread. Release both hooks on transitions or
attachment failure. Keep Scintilla-specific hooks confined to 64-bit builds.
Expand Edit integration checks with read-only multiline navigation and
unsuccessful deletion, plus 32-bit editor/Windows Forms lifecycle support.

Add 32-bit/64-bit native capture fixtures, wire-protocol integration checks,
and design/implementation documents. Document exact schema, capture limits,
installation dependencies, compatibility, and live acceptance procedures.

Validation during commit preparation:
- git diff --check passed.
- cmake --build build --parallel passed.
- CTest could not start because xvfb-run is unavailable in this environment.
- Further tests were skipped at the user's request; capture, protocol, and
  menu/editor integration tests were not run during commit preparation.

Live speech and physical braille checks remain necessary, including review
without moving the native caret, incoming output, plugin dialogs, and return
to Linux/Fenrir. Custom painted output requires an accessible provider;
RichEdit/Scintilla capture, semantic object review, Say All, typing echo, and
native Wine braille routing remain unsupported by this protocol.
This commit is contained in:
deedra committed 2026-10-11 20:50:56 -07:00
1 parent 872388a59b
commit a8cb350467
21 files changed
+2401 -38

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "review_service.h"
#include <condition_variable>
#include <chrono>
#include <deque>
#include <atomic>
#include <cstring>
#include <unistd.h>
namespace {
constexpr const char *reader = "org.stormux.Cthulhu1.Service";
constexpr const char *reader_path = "/org/stormux/Cthulhu1/Service";
constexpr const char *path = "/org/stormux/Cthulhu1/WineReview";
constexpr const char *xml = R"(<node><interface name="org.stormux.Cthulhu1.WineReview">
<method name="CaptureWindow"><arg type="s" direction="in"/><arg type="s" direction="in"/>
<arg type="t" direction="in"/><arg type="a{sv}" direction="out"/></method></interface></node>)";
GVariant *bounds(const RECT &r) {
return g_variant_new("(iiii)", r.left, r.top, r.right-r.left, r.bottom-r.top);
}
void field(GVariantBuilder &b, const char *key, const std::string &value) {
g_variant_builder_add(&b, "{sv}", key, g_variant_new_string(value.c_str()));
}
void flag(GVariantBuilder &b, const char *key, bool value) {
g_variant_builder_add(&b,"{sv}",key,g_variant_new_boolean(value));
}
GVariant *serialize(const ReviewCapture &capture) {
GVariantBuilder b, nodes;
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
g_variant_builder_add(&b,"{sv}","version",g_variant_new_uint32(1));
field(b,"instance",capture.source.instance); field(b,"window",capture.source.window);
field(b,"focus",capture.source.focus); field(b,"status",capture.status);
g_variant_builder_add(&b,"{sv}","revision",g_variant_new_uint64(capture.source.revision));
g_variant_builder_add(&b,"{sv}","bounds",bounds(capture.source.bounds));
g_variant_builder_init(&nodes,G_VARIANT_TYPE("aa{sv}"));
if (capture.status=="ok") for (const auto &node:capture.nodes) {
GVariantBuilder n,ranges;
g_variant_builder_init(&n,G_VARIANT_TYPE_VARDICT);
field(n,"id",node.id); field(n,"parent",node.parent); field(n,"role",node.role); field(n,"name",node.name);
flag(n,"protected",node.protected_text); flag(n,"visible",node.visible);
flag(n,"text_known",node.text_known); flag(n,"caret_known",node.caret_known);
g_variant_builder_add(&n,"{sv}","caret",g_variant_new_uint64(node.caret));
g_variant_builder_add(&n,"{sv}","caret_utf16",g_variant_new_uint64(node.caret_utf16));
g_variant_builder_add(&n,"{sv}","bounds",bounds(node.bounds));
g_variant_builder_init(&ranges,G_VARIANT_TYPE("a(tttts(iiii))"));
if (!node.protected_text) for (const auto &range:node.ranges)
g_variant_builder_add(&ranges,"(tttts(iiii))",range.start,range.end,range.utf16_start,range.utf16_end,
range.text.c_str(),range.bounds.left,range.bounds.top,range.bounds.right-range.bounds.left,range.bounds.bottom-range.bounds.top);
g_variant_builder_add(&n,"{sv}","ranges",g_variant_builder_end(&ranges));
g_variant_builder_add_value(&nodes,g_variant_builder_end(&n));
}
g_variant_builder_add(&b,"{sv}","nodes",g_variant_builder_end(&nodes));
GVariant *result=g_variant_ref_sink(g_variant_builder_end(&b));
if (g_variant_get_size(result)>2097152) {
g_variant_unref(result);
ReviewCapture limited; limited.source=capture.source; limited.status="limited";
return serialize(limited);
}
return result;
}
bool same_notice(const ReviewFocus &a,const ReviewFocus &b) {
return a.instance==b.instance && a.window==b.window && a.focus==b.focus && a.revision==b.revision &&
a.title==b.title && a.role==b.role && a.protected_text==b.protected_text && EqualRect(&a.bounds,&b.bounds);
}
struct Job {
ReviewFocus focus;
uint64_t deadline;
GDBusMethodInvocation *invocation;
bool ready=false, replied=false;
ReviewCapture capture;
~Job() { g_object_unref(invocation); }
};
} // namespace
struct ReviewService::State {
explicit State(GDBusConnection *c,ReviewIdentityStore &s):connection(G_DBUS_CONNECTION(g_object_ref(c))),store(s) {
context=g_main_context_new(); loop=g_main_loop_new(context,FALSE);
calls=g_cancellable_new();
}
~State() { g_object_unref(calls); g_main_loop_unref(loop); g_main_context_unref(context); g_object_unref(connection); }
GDBusConnection *connection;
ReviewIdentityStore store;
GMainContext *context;
GMainLoop *loop;
GCancellable *calls;
std::mutex mutex;
std::condition_variable changed;
ReviewFocus focus, sent;
std::deque<std::shared_ptr<Job>> pending;
std::shared_ptr<Job> active;
std::atomic<bool> stopping{false};
bool worker_done=false, ready=false, exported=false;
// These fields belong exclusively to the GLib dispatcher.
std::string owner;
bool same_user=false, registered=false, announcing=false;
uint64_t next_registration=0;
guint object_id=0, owner_subscription=0;
static DWORD WINAPI work(LPVOID);
static DWORD WINAPI dispatch(LPVOID);
void reply(const std::shared_ptr<Job> &job,const ReviewCapture &capture) {
if (job->replied) return;
job->replied=true;
GVariant *data=serialize(capture);
g_dbus_method_invocation_return_value(job->invocation,g_variant_new("(@a{sv})",data));
}
std::string lookup_owner() {
GError *error=nullptr;
GVariant *value=g_dbus_connection_call_sync(connection,"org.freedesktop.DBus","/org/freedesktop/DBus",
"org.freedesktop.DBus","GetNameOwner",g_variant_new("(s)",reader),G_VARIANT_TYPE("(s)"),
G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error);
std::string result;
if (value) { const gchar *name=nullptr; g_variant_get(value,"(&s)",&name); result=name; g_variant_unref(value); }
if (error) g_error_free(error);
return result;
}
void refresh_owner() {
const auto current=lookup_owner();
if (current==owner) return;
owner=current; registered=false; same_user=false; sent={}; next_registration=0;
if (owner.empty()) return;
GError *error=nullptr;
GVariant *value=g_dbus_connection_call_sync(connection,"org.freedesktop.DBus","/org/freedesktop/DBus",
"org.freedesktop.DBus","GetConnectionUnixUser",g_variant_new("(s)",owner.c_str()),G_VARIANT_TYPE("(u)"),
G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error);
if (value) { guint uid=0; g_variant_get(value,"(u)",&uid); same_user=uid==getuid(); g_variant_unref(value); }
if (error) g_error_free(error);
}
static void called(GObject *connection,GAsyncResult *result,gpointer data) {
auto state=static_cast<std::shared_ptr<State>*>(data);
GError *error=nullptr;
GVariant *value=g_dbus_connection_call_finish(G_DBUS_CONNECTION(connection),result,&error);
// Completion from an older owner may only allow a retry, never grant
// access or retain that owner's focus as the new provider registration.
(*state)->announcing=false;
gboolean accepted=FALSE;
if (value) { g_variant_get(value,"(b)",&accepted); g_variant_unref(value); }
if (!accepted) (*state)->sent={};
if (error) g_error_free(error);
delete state;
}
void announce(const std::shared_ptr<State> &self) {
if (announcing || owner.empty() || !same_user || stopping) return;
const uint64_t now=GetTickCount64();
if (!registered) {
if (now<next_registration) return;
next_registration=now+1000;
GError *error=nullptr;
GVariant *value=g_dbus_connection_call_sync(connection,owner.c_str(),reader_path,reader,"RegisterWineReviewProvider",
g_variant_new("(ou)",path,1u),G_VARIANT_TYPE("(b)"),G_DBUS_CALL_FLAGS_NONE,100,nullptr,&error);
gboolean accepted=FALSE;
if (value) { g_variant_get(value,"(b)",&accepted); g_variant_unref(value); }
if (error) g_error_free(error);
registered=accepted;
}
ReviewFocus notice;
{ std::lock_guard<std::mutex> lock(mutex); notice=focus; }
if (!registered || notice.focus.empty() || same_notice(notice,sent)) return;
sent=notice; announcing=true;
g_dbus_connection_call(connection,owner.c_str(),reader_path,reader,"PresentWineReviewFocus",
g_variant_new("(ssstssb(iiii))",notice.instance.c_str(),notice.window.c_str(),notice.focus.c_str(),notice.revision,
notice.title.c_str(),notice.role.c_str(),notice.protected_text,notice.bounds.left,notice.bounds.top,
notice.bounds.right-notice.bounds.left,notice.bounds.bottom-notice.bounds.top),
G_VARIANT_TYPE("(b)"),G_DBUS_CALL_FLAGS_NONE,100,calls,called,new std::shared_ptr<State>(self));
}
static void method(GDBusConnection *,const gchar *sender,const gchar *,const gchar *,const gchar *,
GVariant *parameters,GDBusMethodInvocation *invocation,gpointer data) {
auto &state=*static_cast<State*>(data);
const uint64_t received=GetTickCount64();
if (!state.same_user || state.owner!=sender || state.lookup_owner()!=sender) {
g_dbus_method_invocation_return_dbus_error(invocation,"org.freedesktop.DBus.Error.AccessDenied","Only the current Cthulhu service may capture Wine review."); return;
}
const char *window=nullptr,*focus=nullptr; guint64 revision=0;
g_variant_get(parameters,"(&s&st)",&window,&focus,&revision);
if (strlen(window)>256 || strlen(focus)>256) {
g_dbus_method_invocation_return_dbus_error(invocation,"org.freedesktop.DBus.Error.InvalidArgs","Review identities exceed their limit."); return;
}
auto job=std::make_shared<Job>(); job->deadline=received+500;
job->invocation=G_DBUS_METHOD_INVOCATION(g_object_ref(invocation));
std::lock_guard<std::mutex> lock(state.mutex);
job->focus=state.focus;
ReviewCapture rejected; rejected.source=job->focus;
if (state.stopping) { state.reply(job,rejected); return; }
if (job->focus.window!=window || job->focus.focus!=focus || job->focus.revision!=revision) {
rejected.status="stale"; state.reply(job,rejected); return;
}
if (state.pending.size()>=1) { rejected.status="busy"; state.reply(job,rejected); return; }
state.pending.push_back(job); state.changed.notify_one();
}
void expire() {
std::lock_guard<std::mutex> lock(mutex);
const auto now=GetTickCount64();
auto finish=[&](const std::shared_ptr<Job> &job) {
if (!job || job->replied) return;
if (job->ready && now<job->deadline && !stopping) reply(job,job->capture);
else if (now>=job->deadline || stopping) { ReviewCapture expired; expired.source=job->focus; reply(job,expired); }
};
finish(active);
for (auto &job:pending) finish(job);
while (!pending.empty() && pending.front()->replied) pending.pop_front();
if (active && active->ready && active->replied) active.reset();
}
};
ReviewService::~ReviewService() { stop(); }
DWORD WINAPI ReviewService::State::work(LPVOID pointer) {
std::unique_ptr<std::shared_ptr<State>> retained(static_cast<std::shared_ptr<State>*>(pointer));
auto state=*retained;
const HRESULT com=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
for (;;) {
std::shared_ptr<Job> job;
{
std::unique_lock<std::mutex> lock(state->mutex);
state->changed.wait(lock,[&] { return state->stopping || (!state->active && !state->pending.empty()); });
if (state->stopping) break;
job=state->pending.front(); state->pending.pop_front(); state->active=job;
}
ReviewCapture result; result.source=job->focus;
if (SUCCEEDED(com) && GetTickCount64()<job->deadline) result=capture_review_window(state->store,job->focus,job->deadline);
{ std::lock_guard<std::mutex> lock(state->mutex); job->capture=std::move(result); job->ready=true; }
// Dispatcher returns or expires the result before allowing the next
// native capture. The capture STA never handles D-Bus invocations.
}
if (SUCCEEDED(com)) CoUninitialize();
{ std::lock_guard<std::mutex> lock(state->mutex); state->worker_done=true; state->changed.notify_all(); }
return 0;
}
bool ReviewService::start(GDBusConnection *connection,ReviewIdentityStore &store) {
if (state_) return false;
state_=std::make_shared<State>(connection,store);
auto state=state_;
auto worker_state=new std::shared_ptr<State>(state);
worker_=CreateThread(nullptr,0,State::work,worker_state,0,nullptr);
if (!worker_) { delete worker_state; state_.reset(); return false; }
auto dispatch_state=new std::shared_ptr<State>(state);
dispatcher_=CreateThread(nullptr,0,State::dispatch,dispatch_state,0,nullptr);
if (!dispatcher_) {
delete dispatch_state;
{ std::lock_guard<std::mutex> lock(state->mutex); state->stopping=true; state->changed.notify_all(); }
WaitForSingleObject(worker_,1000); CloseHandle(worker_); worker_=nullptr;
state_.reset(); return false;
}
std::unique_lock<std::mutex> lock(state->mutex);
state->changed.wait_for(lock,std::chrono::seconds(1),[&] { return state->ready; });
return state->exported;
}
DWORD WINAPI ReviewService::State::dispatch(LPVOID pointer) {
std::unique_ptr<std::shared_ptr<State>> retained(static_cast<std::shared_ptr<State>*>(pointer));
auto state=*retained;
g_main_context_push_thread_default(state->context);
GError *error=nullptr;
GDBusNodeInfo *info=g_dbus_node_info_new_for_xml(xml,&error);
static const GDBusInterfaceVTable vtable{State::method,nullptr,nullptr,{nullptr}};
if (info) state->object_id=g_dbus_connection_register_object(state->connection,path,info->interfaces[0],&vtable,state.get(),nullptr,&error);
if (error) g_error_free(error);
if (info) g_dbus_node_info_unref(info);
{
std::lock_guard<std::mutex> lock(state->mutex);
state->exported=state->object_id!=0; state->ready=true; state->changed.notify_all();
}
GSource *tick=g_timeout_source_new(10);
g_source_set_callback(tick,[](gpointer data)->gboolean {
auto self=*static_cast<std::shared_ptr<State>*>(data);
self->expire(); self->changed.notify_one();
static_cast<void>(self);
self->announce(self);
return G_SOURCE_CONTINUE;
},new std::shared_ptr<State>(state),[](gpointer p) { delete static_cast<std::shared_ptr<State>*>(p); });
g_source_attach(tick,state->context);
state->owner_subscription=g_dbus_connection_signal_subscribe(state->connection,"org.freedesktop.DBus","org.freedesktop.DBus",
"NameOwnerChanged","/org/freedesktop/DBus",reader,G_DBUS_SIGNAL_FLAGS_NONE,
[](GDBusConnection*,const gchar*,const gchar*,const gchar*,const gchar*,GVariant*,gpointer p) {
static_cast<State*>(p)->refresh_owner();
},state.get(),nullptr);
state->refresh_owner();
g_main_loop_run(state->loop);
g_cancellable_cancel(state->calls);
while (state->announcing) g_main_context_iteration(state->context,TRUE);
state->expire();
g_source_destroy(tick); g_source_unref(tick);
if (state->owner_subscription) g_dbus_connection_signal_unsubscribe(state->connection,state->owner_subscription);
if (state->object_id) g_dbus_connection_unregister_object(state->connection,state->object_id);
g_main_context_pop_thread_default(state->context);
return 0;
}
void ReviewService::focus_changed(const ReviewFocus &focus) {
if (!state_) return;
std::lock_guard<std::mutex> lock(state_->mutex);
state_->focus=focus;
}
void ReviewService::stop() {
auto state=state_;
if (!state) return;
{
std::lock_guard<std::mutex> lock(state->mutex);
state->stopping=true; state->changed.notify_all();
}
g_main_context_invoke(state->context,[](gpointer p)->gboolean {
auto state=static_cast<std::shared_ptr<State>*>(p);
g_main_loop_quit((*state)->loop); delete state; return G_SOURCE_REMOVE;
},new std::shared_ptr<State>(state));
if (dispatcher_) { WaitForSingleObject(dispatcher_,1000); CloseHandle(dispatcher_); dispatcher_=nullptr; }
std::unique_lock<std::mutex> lock(state->mutex);
state->changed.wait_for(lock,std::chrono::milliseconds(100),[&] { return state->worker_done; });
bool done=state->worker_done; lock.unlock();
if (worker_) {
if (done) WaitForSingleObject(worker_,100);
CloseHandle(worker_); worker_=nullptr;
}
state_.reset();
}