Files
baketnk--frame-yap/src/focus_guard.cpp
T
baketnkandClaude Opus 5.5 9ec5dd03ba Fix paced delivery stopping after the first 24-codepoint chunk
Gamescope rewrites GAMESCOPE_FOCUSED_WINDOW (same window) after an IME
commit. The focus guard treated any PropertyNotify on the root focus
properties as a focus change, so every paced delivery stopped after its
first chunk. Re-read the property and fail only when it names a
different window; real X focus moves still fail via FocusOut, and every
check still re-validates both properties and X input focus.

Diagnosed from delivery-debug.log on Frame:
  sent offset=0 bytes=24
  focus invalid: GAMESCOPE_FOCUSED_WINDOW changed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:49:52 -04:00

204 lines
9.9 KiB
C++

#include "focus_guard.hpp"
#include <xcb/xcb.h>
#include <cstdlib>
#include <cstring>
#include <memory>
namespace frameyap {
namespace {
struct ReplyDeleter { void operator()(void* p) const { std::free(p); } };
template<class T> using Reply = std::unique_ptr<T, ReplyDeleter>;
}
struct FocusGuard::Impl {
xcb_connection_t* connection = nullptr;
xcb_window_t root = XCB_WINDOW_NONE;
xcb_window_t target = XCB_WINDOW_NONE;
xcb_atom_t active_atom = XCB_ATOM_NONE;
xcb_atom_t gamescope_atom = XCB_ATOM_NONE;
bool attempted = false;
bool armed = false;
bool dead = false;
const char* reason = "none";
~Impl() { if (connection) xcb_disconnect(connection); }
bool failed() const { return dead || !connection || xcb_connection_has_error(connection); }
bool atom(const char* name, xcb_atom_t& value) {
auto cookie = xcb_intern_atom(connection, 0,
static_cast<uint16_t>(std::strlen(name)), name);
xcb_generic_error_t* error = nullptr;
Reply<xcb_intern_atom_reply_t> reply(xcb_intern_atom_reply(connection, cookie, &error));
Reply<xcb_generic_error_t> error_owner(error);
if (!reply || error || failed()) return false;
value = reply->atom;
return value != XCB_ATOM_NONE;
}
bool select(xcb_window_t window, uint32_t mask) {
auto cookie = xcb_change_window_attributes_checked(connection, window,
XCB_CW_EVENT_MASK, &mask);
Reply<xcb_generic_error_t> error(xcb_request_check(connection, cookie));
return !error && !failed();
}
bool property_window(xcb_atom_t property, xcb_atom_t required_type,
xcb_window_t& result) {
auto cookie = xcb_get_property(connection, 0, root, property,
required_type, 0, 1);
xcb_generic_error_t* error = nullptr;
Reply<xcb_get_property_reply_t> reply(xcb_get_property_reply(connection, cookie, &error));
Reply<xcb_generic_error_t> error_owner(error);
if (!reply || error || failed() || reply->type != required_type ||
reply->format != 32 || reply->value_len != 1 || reply->bytes_after != 0)
return false;
result = *static_cast<const uint32_t*>(xcb_get_property_value(reply.get()));
return result != XCB_WINDOW_NONE;
}
bool focus(xcb_window_t& result) {
auto cookie = xcb_get_input_focus(connection);
xcb_generic_error_t* error = nullptr;
Reply<xcb_get_input_focus_reply_t> reply(xcb_get_input_focus_reply(connection, cookie, &error));
Reply<xcb_generic_error_t> error_owner(error);
if (!reply || error || failed()) return false;
result = reply->focus;
return result != XCB_WINDOW_NONE;
}
bool keys_up() {
auto cookie = xcb_query_keymap(connection);
xcb_generic_error_t* error = nullptr;
Reply<xcb_query_keymap_reply_t> reply(xcb_query_keymap_reply(connection, cookie, &error));
Reply<xcb_generic_error_t> error_owner(error);
if (!reply || error || failed()) return false;
for (unsigned char byte : reply->keys) if (byte != 0) return false;
return true;
}
bool snapshot(xcb_window_t& current) {
xcb_window_t active = XCB_WINDOW_NONE, gamescope = XCB_WINDOW_NONE, actual = XCB_WINDOW_NONE;
if (!property_window(active_atom, XCB_ATOM_WINDOW, active)) { reason = "no _NET_ACTIVE_WINDOW"; return false; }
if (!property_window(gamescope_atom, XCB_ATOM_CARDINAL, gamescope)) { reason = "no GAMESCOPE_FOCUSED_WINDOW"; return false; }
if (!focus(actual)) { reason = "no X input focus"; return false; }
if (active != gamescope) { reason = "active != gamescope focus"; return false; }
if (active != actual) { reason = "active != X input focus"; return false; }
if (!keys_up()) { reason = "key held"; return false; }
current = active;
return true;
}
bool drain_events() {
while (xcb_generic_event_t* event = xcb_poll_for_event(connection)) {
const uint8_t type = event->response_type & 0x7f;
bool bad = type == 0; // asynchronous X error
if (bad) reason = "X error";
if (type == XCB_PROPERTY_NOTIFY) {
const auto* e = reinterpret_cast<xcb_property_notify_event_t*>(event);
// Gamescope rewrites its focus properties with the same window
// after IME commits; only a different value is a focus change.
// A real X focus move still reports FocusOut on the target.
xcb_window_t value = XCB_WINDOW_NONE;
if (e->window == root && e->atom == active_atom &&
(!property_window(active_atom, XCB_ATOM_WINDOW, value) || value != target)) {
bad = true; reason = "_NET_ACTIVE_WINDOW changed";
}
if (e->window == root && e->atom == gamescope_atom &&
(!property_window(gamescope_atom, XCB_ATOM_CARDINAL, value) || value != target)) {
bad = true; reason = "GAMESCOPE_FOCUSED_WINDOW changed";
}
} else if (type == XCB_FOCUS_OUT) {
const auto* e = reinterpret_cast<xcb_focus_out_event_t*>(event);
if (e->event == target) { bad = true; reason = "FocusOut on target"; }
} else if (type == XCB_DESTROY_NOTIFY) {
const auto* e = reinterpret_cast<xcb_destroy_notify_event_t*>(event);
if (e->window == target) { bad = true; reason = "target destroyed"; }
} else if (type == XCB_CREATE_NOTIFY || type == XCB_UNMAP_NOTIFY ||
type == XCB_MAP_NOTIFY || type == XCB_MAP_REQUEST ||
type == XCB_REPARENT_NOTIFY || type == XCB_CONFIGURE_NOTIFY ||
type == XCB_CONFIGURE_REQUEST || type == XCB_GRAVITY_NOTIFY ||
type == XCB_RESIZE_REQUEST || type == XCB_CIRCULATE_NOTIFY ||
type == XCB_CIRCULATE_REQUEST) {
// Any structural change on the observed target is ambiguous.
xcb_window_t window = XCB_WINDOW_NONE;
switch (type) {
case XCB_CREATE_NOTIFY: window = reinterpret_cast<xcb_create_notify_event_t*>(event)->parent; break;
case XCB_UNMAP_NOTIFY: window = reinterpret_cast<xcb_unmap_notify_event_t*>(event)->window; break;
case XCB_MAP_NOTIFY: window = reinterpret_cast<xcb_map_notify_event_t*>(event)->window; break;
case XCB_MAP_REQUEST: window = reinterpret_cast<xcb_map_request_event_t*>(event)->window; break;
case XCB_REPARENT_NOTIFY: window = reinterpret_cast<xcb_reparent_notify_event_t*>(event)->window; break;
case XCB_CONFIGURE_NOTIFY: window = reinterpret_cast<xcb_configure_notify_event_t*>(event)->window; break;
case XCB_CONFIGURE_REQUEST: window = reinterpret_cast<xcb_configure_request_event_t*>(event)->window; break;
case XCB_GRAVITY_NOTIFY: window = reinterpret_cast<xcb_gravity_notify_event_t*>(event)->window; break;
case XCB_RESIZE_REQUEST: window = reinterpret_cast<xcb_resize_request_event_t*>(event)->window; break;
case XCB_CIRCULATE_NOTIFY: window = reinterpret_cast<xcb_circulate_notify_event_t*>(event)->window; break;
case XCB_CIRCULATE_REQUEST: window = reinterpret_cast<xcb_circulate_request_event_t*>(event)->window; break;
}
if (window == target) { bad = true; reason = "structure event on target"; }
}
std::free(event);
if (bad) return false;
}
if (failed()) { reason = "X connection failed"; return false; }
return true;
}
bool check() {
if (!armed || dead) { reason = armed ? reason : "not armed"; dead = true; return false; }
if (failed() || !drain_events()) { dead = true; return false; }
xcb_window_t current = XCB_WINDOW_NONE;
if (!snapshot(current)) { dead = true; return false; }
if (current != target) { reason = "focus moved to another window"; dead = true; return false; }
if (!drain_events()) {
dead = true;
return false;
}
return true;
}
};
FocusGuard::FocusGuard() : impl_(std::make_unique<Impl>()) {}
FocusGuard::~FocusGuard() = default;
bool FocusGuard::arm() {
auto& p = *impl_;
if (p.attempted || p.dead) return false;
p.attempted = true;
int screen_number = 0;
p.connection = xcb_connect(nullptr, &screen_number);
if (!p.connection || xcb_connection_has_error(p.connection)) { p.dead = true; return false; }
const xcb_setup_t* setup = xcb_get_setup(p.connection);
auto iter = xcb_setup_roots_iterator(setup);
for (int i = 0; i < screen_number && iter.rem; ++i) xcb_screen_next(&iter);
if (!iter.rem) { p.dead = true; return false; }
p.root = iter.data->root;
if (!p.atom("_NET_ACTIVE_WINDOW", p.active_atom) ||
!p.atom("GAMESCOPE_FOCUSED_WINDOW", p.gamescope_atom)) { p.dead = true; return false; }
xcb_window_t before = XCB_WINDOW_NONE;
if (!p.snapshot(before)) { p.dead = true; return false; }
p.target = before;
if (!p.select(p.root, XCB_EVENT_MASK_PROPERTY_CHANGE) ||
!p.select(p.target, XCB_EVENT_MASK_FOCUS_CHANGE | XCB_EVENT_MASK_STRUCTURE_NOTIFY)) {
p.dead = true;
return false;
}
// Validate after subscriptions so a target/property transition during setup
// cannot silently authorize the capture.
xcb_window_t after = XCB_WINDOW_NONE;
if (!p.snapshot(after) || after != p.target || !p.drain_events()) {
p.dead = true;
return false;
}
p.armed = true;
return true;
}
bool FocusGuard::valid() { return impl_->check(); }
const char* FocusGuard::failure() const { return impl_->reason; }
void FocusGuard::invalidate() { impl_->dead = true; impl_->armed = false; }
} // namespace frameyap