Add opt-in fail-closed Xwayland auto insert

This commit is contained in:
baketnk committed 2026-09-24 16:12:33 -04:00
1 parent 19a3db7667
commit c5e925cb48
27 files changed
+588 -45

No files matched your search

+15 -2
View File
@@ -66,6 +66,7 @@ if(FRAMEYAP_NATIVE)
set_property(TARGET PkgConfig::SDL3 PROPERTY INTERFACE_LINK_OPTIONS "${SDL3_LINK_OPTIONS}")
endif()
pkg_check_modules(WAYLAND REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(XCB REQUIRED IMPORTED_TARGET xcb)
find_program(WAYLAND_SCANNER wayland-scanner REQUIRED)
set(OPENVR_ROOT "" CACHE PATH "Explicit standalone OpenVR v2.15.6 SDK root")
find_path(OPENVR_INCLUDE_DIR openvr.h HINTS "${OPENVR_ROOT}/headers" REQUIRED)
@@ -84,10 +85,10 @@ if(FRAMEYAP_NATIVE)
add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c"
COMMAND "${WAYLAND_SCANNER}" private-code "${PROTOCOL}" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c"
DEPENDS "${PROTOCOL}" VERBATIM)
target_sources(frameyap PRIVATE src/runtime.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp
target_sources(frameyap PRIVATE src/runtime.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp src/focus_guard.cpp
"${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h")
target_include_directories(frameyap PRIVATE "${OPENVR_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(frameyap PRIVATE frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND Vulkan::Vulkan "${OPENVR_LIBRARY}")
target_link_libraries(frameyap PRIVATE frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}")
target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1)
set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib")
endif()
@@ -136,6 +137,18 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
add_test(NAME frameyap.gestures COMMAND frameyap_gestures_test)
find_package(Python3 3.10 COMPONENTS Interpreter)
if(FRAMEYAP_NATIVE)
add_executable(frameyap_focus_guard_test tests/focus_guard_test.cpp src/focus_guard.cpp)
target_include_directories(frameyap_focus_guard_test PRIVATE src)
target_link_libraries(frameyap_focus_guard_test PRIVATE PkgConfig::XCB)
add_test(NAME frameyap.focus_guard COMMAND frameyap_focus_guard_test)
find_program(XVFB_RUN xvfb-run)
if(XVFB_RUN)
add_executable(frameyap_focus_guard_xcb_test tests/focus_guard_xcb_test.cpp src/focus_guard.cpp)
target_include_directories(frameyap_focus_guard_xcb_test PRIVATE src)
target_link_libraries(frameyap_focus_guard_xcb_test PRIVATE PkgConfig::XCB)
target_compile_options(frameyap_focus_guard_xcb_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.focus_guard_xcb COMMAND "${XVFB_RUN}" -a $<TARGET_FILE:frameyap_focus_guard_xcb_test>)
endif()
add_executable(frameyap_overlay_texture_test tests/overlay_texture_test.cpp src/overlay_texture.cpp)
target_include_directories(frameyap_overlay_texture_test PRIVATE src "${OPENVR_INCLUDE_DIR}" "${Vulkan_INCLUDE_DIRS}")
target_compile_options(frameyap_overlay_texture_test PRIVATE -UNDEBUG)
+8 -3
View File
@@ -26,7 +26,7 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
- **Right B:** cancel/discard. **Right A:** insert reviewed text with a trailing space.
**Right Y:** insert pending text, then send Enter; with no pending text, Enter only.
**Left grip (when active):** double-tap for the same explicit Enter action.
Nothing inserts or submits automatically after transcription.
Nothing submits automatically. Auto Insert is opt-in and off by default.
- **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit.
Review and settings share one Inconsolata/neon-framed surface.
Drag the inset lower-right grip to resize the panel (world, head or wrist);
@@ -49,8 +49,13 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
overlays**. FrameYap then requests priority for its bound controller sources.
This may consume controls used by games or the dashboard; coexistence on Frame
is under test. The default is `"normal"`; restart FrameYap after changing it.
- **Review-first:** focus your destination, then press Insert (text + space) or
explicitly Enter (text + space, then Enter). No automatic insertion or submission. Maximum clip 20 seconds; accidental taps under 200 ms are discarded.
- **Review by default:** focus your destination, then press Insert (text + space) or
explicitly Enter (text + space, then Enter). Settings → Auto insert is off by default:
when enabled, it queues text + space only if Xwayland keyboard focus, active
window and Gamescope focus match continuously from recording through delivery.
Any uncertainty leaves a preview for manual Insert; it never sends Enter.
This is not yet validated for live transcription on Frame. Maximum clip 20 seconds;
accidental taps under 200 ms are discarded.
- While the native app is Ready, it keeps the mic device open and discards idle
audio instead of opening/closing on every PTT. Quit releases the device. Other
apps may still hear/transmit your voice; FrameYap does not mute them.
+1
View File
@@ -2,6 +2,7 @@
"font": "",
"input_priority": "normal",
"advanced_debug": false,
"auto_insert": false,
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": {
"background": "#0c101b",
+7 -5
View File
@@ -5,7 +5,8 @@ Standalone project design; see
This document is the full target design, not a blanket implementation claim.
The native POC now implements overlay/actions, bounded SDL3 capture, a persistent
Redux adapter, review-first Gamescope insertion and a user-local archive installer.
Quick typing/focus-generation tracking, polished status-chip UX and full hardware
Opt-in conservative Xwayland focus tracking is implemented locally but not yet
accepted for live automatic typing on Frame. Polished status-chip UX and full hardware
acceptance remain proposed. See [current scope](poc.md), [device observations](evidence/poc-cpu-overlay-2026-09-24.md)
and [runtime licensing boundary](third-party.md).
@@ -66,13 +67,14 @@ Recording… 00:04 [ Cancel ]
A click-to-start/stop overlay button provides
a binding-independent alternative. Bound recording to 20 seconds; discard
accidental taps (initial threshold: 200 ms).
- **Quick typing:** insert on completion only when the explicitly armed target
is still valid. **Review mode:** always wait for Insert. Bring up review instead
of silently losing a transcript or typing into a new target.
- **Quick typing (opt-in, locally implemented):** insert on completion only when
uninterrupted Xwayland target observation remains valid. **Review mode (default):**
wait for Insert. Bring up review on uncertainty rather than silently losing a
transcript or typing into a new target. Live Frame acceptance remains open.
- Enter requires its own explicit control activation: insert pending review with
a trailing space, then queue Enter only if the text step succeeds. With no
review, it queues only Enter. Never interpret "submit", "delete" or other speech
as commands. Transcription completion never inserts or auto-submits.
as commands. Transcription completion never auto-submits.
- No generic "undo last dictation" initially: another application's edits/cursor
cannot be reliably rolled back by a guessed number of backspaces.
- Stop/disable releases owned keys and microphone, invalidates pending delivery,
+29
View File
@@ -0,0 +1,29 @@
# Guided Frame focus probe — 2026-09-24
Historical observation, not authorization for later live input or proof of target safety.
The wearer consented to an opt-in, disposable-target focus trial. Two project-owned
`xmessage` windows were created on Xwayland `:0` for 65 seconds, then closed by
the owning finite command. No microphone, transcript, input injection or SteamVR
session cleanup was used. Other SSH/user processes were left untouched.
An earlier read-only snapshot showed `/tmp/.X11-unix/X0` and `X1`, Gamescope
socket `gamescope-0`, and `_NET_ACTIVE_WINDOW` matching
`GAMESCOPE_FOCUSED_WINDOW` on `:0`. Brief snapshots of an owned test window
also showed disagreement between these root properties, so either property
alone is insufficient to authorize automatic typing.
The wearer selected A/B and reported doing several focus changes. Window A was
`0x3e00022`, B was `0x4200022` in that run. A 120 ms sampled observer saw
X keyboard focus, `_NET_ACTIVE_WINDOW` and `GAMESCOPE_FOCUSED_WINDOW` agree at
A, change to B, then return to A several times. A transition to a third window
`0x3c00003` was also observed. At other moments the X keyboard-focus/active
window IDs changed while Gamescope's focused-window property still named A.
The root property is a window ID encoded as CARDINAL, not a generation token.
These samples establish *observable correlation for these two owned Xwayland
windows*, not continuous seat identity across all targets, a focus-loss event
stream, transcript quality, delivered input, native Wayland coverage or headset
acceptance. The offline fail-closed observer subscribes to X property changes
and focus-out on the exact armed window; its own live behavior and actual IME
delivery still require a separate disposable-target validation. There is still
a non-atomic gap between final focus check and compositor input processing.
+21 -1
View File
@@ -63,7 +63,9 @@ tab changes, action-state changes and relocation clear pending presses. Enter is
deliberate action, not inferred from text. Insert appends a trailing space (without
doubling an existing trailing space). Enter inserts any pending review and then
queues Enter; with no pending text it queues Enter only. A failed text step never
proceeds to Enter. Recording never automatically inserts or submits.
proceeds to Enter. Recording never automatically submits. Auto insert, when
explicitly enabled, can queue text + space after transcription only under the
stable Xwayland focus guard described below.
### Bindings button
@@ -95,6 +97,7 @@ installer creates one with defaults on first install. Copy the shipped
"font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"input_priority": "normal",
"advanced_debug": false,
"auto_insert": false,
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": {
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
@@ -125,6 +128,23 @@ Detailed logs are bounded and owner-private; see [worker diagnostics](worker.md#
config, retaining other fields and formatting; invalid/unwritable configs are
left untouched and return failure. The installer backs up original bytes before
repairing invalid values, while valid `true` and `false` are retained.
`auto_insert` is a separate boolean, default `false`, also available as a
Settings toggle. Only a **new** recording arms it. It observes the Xwayland
display selected by `DISPLAY`; its root `_NET_ACTIVE_WINDOW` and
`GAMESCOPE_FOCUSED_WINDOW` must agree with the exact X keyboard-focus window.
Both properties and focus are rechecked after IME lease acquisition. A watched
focus-out, root focus-property change (even if the same window returns), window
destruction, held keyboard key, missing X display or any disagreement permanently
disarms that clip. The transcript then remains for explicit review/Insert.
Native Wayland focus and child text-field focus cannot be safely inferred here;
those cases fall back to review. No automatic Enter, speech commands or retry.
The compositor can still change focus in the gap between the final check and
global delivery, and IME commit is not an application receipt. This path has
offline synthetic focus tests; live automatic typing, target coverage and
headset acceptance are still unverified. The setting is preserved on upgrade
and a failed preference write applies only to the current session.
`buttons` maps named OpenVR actions (`left_grip`, `right_grip`, `ptt`, `cancel`,
`insert`, `enter`) to Frame physical `/user/hand/{left|right}/input/NAME`
button paths. Omitted actions retain their bundled defaults; an empty string
+2 -2
View File
@@ -25,8 +25,8 @@ For the current **external-runtime** POC, `scripts/stage-native-poc.py --help`
documents explicit inputs. It invokes `cmake --install` on an existing native build,
copies SDL/OpenVR and an explicitly licensed font, and retains notices. It does
not build, download, run the app, or copy a proprietary ASR runtime. The native
POC relies on Frame's system Vulkan loader/driver, Wayland, FreeType, libstdc++
and glibc; audit `ldd` on the installed binary.
POC relies on Frame's system Vulkan loader/driver, Wayland, libxcb, FreeType,
libstdc++ and glibc; audit `ldd` on the installed binary.
SDL/OpenVR resolve inside its own `lib/`, not a producer
prefix. ARM64/glibc packaging is not a claim of compatibility with arbitrary Linux.
+8 -4
View File
@@ -46,10 +46,14 @@ initialize OpenVR, open a microphone, run ASR, download files or inject input.
## Deliberately not claimed
**Review-first only.** No automatic insertion or inferred commands. A transcript
must be explicitly inserted into the *current* focused destination. We do not yet
implement the proposed Xwayland focus-generation observer or safe quick typing.
There remains a race with focus changes after user approval. Text delivery is
**Review-first by default.** An opt-in Auto insert setting now watches the
Xwayland active window, Gamescope focused-window property and exact keyboard
focus from PTT start to IME lease acquisition. Loss/regain, disagreement,
unavailable focus or a held keyboard key falls back to manual review. No
automatic Enter or inferred speech commands. This focus guard has offline
tests but live microphone → automatic insertion is **not yet accepted** on Frame;
it cannot identify arbitrary native Wayland targets. There remains a race
between the final focus check and global input processing. Text delivery is
reported as **input queued**, not application consumption or message delivery.
A request is consumed once even if transport completion is uncertain; no retries.
Unavailable IME acquisition leaves the preview intact.
+3
View File
@@ -46,6 +46,9 @@ font, places the launcher copy at `fonts/font.ttf`, and includes its license in
retain its LICENSE with redistributed loader binaries.
- SDL3: zlib license; device trial used SDL 3.2.16 built in a private user prefix.
- Wayland client and scanner: retain upstream MIT-style notices.
- libxcb (X11 protocol client): MIT-style license; used only by the opt-in
Xwayland focus observer. Include it in native runtime dependency checks;
no X server is started by normal operation.
- FreeType: choose and comply with its applicable FTL/GPL licensing option.
- Optional font override: the earlier device check used system Hack Regular.
A custom release font must include its own license and assessed glyph coverage;
+3
View File
@@ -35,6 +35,7 @@ CONFIG_DEFAULTS = {
"font": "",
"input_priority": "normal",
"advanced_debug": False,
"auto_insert": False,
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
@@ -95,6 +96,8 @@ def normalized_config(data):
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
debug = data.get("advanced_debug", False)
fixed["advanced_debug"] = debug if type(debug) is bool else False
automatic = data.get("auto_insert", False)
fixed["auto_insert"] = automatic if type(automatic) is bool else False
source = data.get("wrist")
source = source if isinstance(source, dict) else {}
fixed["wrist"] = {}
+3
View File
@@ -24,6 +24,7 @@ CONFIG_DEFAULTS = {
"font": "",
"input_priority": "normal",
"advanced_debug": False,
"auto_insert": False,
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
@@ -84,6 +85,8 @@ def normalized_config(data):
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
debug = data.get("advanced_debug", False)
fixed["advanced_debug"] = debug if type(debug) is bool else False
automatic = data.get("auto_insert", False)
fixed["auto_insert"] = automatic if type(automatic) is bool else False
source = data.get("wrist")
source = source if isinstance(source, dict) else {}
fixed["wrist"] = {}
+14 -3
View File
@@ -197,6 +197,9 @@ Config load_config(const std::filesystem::path& path) {
} else if (key == "advanced_debug") {
if (!value.is_bool) throw std::runtime_error("Config advanced_debug must be a boolean");
config.advanced_debug = value.value == "true";
} else if (key == "auto_insert") {
if (!value.is_bool) throw std::runtime_error("Config auto_insert must be a boolean");
config.auto_insert = value.value == "true";
} else if (key == "input_priority") {
if (!value.is_string || (value.value != "normal" && value.value != "experimental"))
throw std::runtime_error("Config input_priority must be normal or experimental");
@@ -250,7 +253,8 @@ Config load_config(const std::filesystem::path& path) {
}
return config;
}
bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept {
namespace {
bool save_bool_option(const std::filesystem::path& path, std::string_view key, bool enabled) noexcept {
try {
if (path.empty() || !path.is_absolute() || std::filesystem::is_symlink(path)) return false;
const bool existing = std::filesystem::exists(path);
@@ -261,14 +265,14 @@ bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexce
auto root = parser.parse(); parser.ws();
if (!root.is_object || parser.pos != bytes.size()) return false;
const std::string value = enabled ? "true" : "false";
auto it = root.object.find("advanced_debug");
auto it = root.object.find(std::string(key));
if (it != root.object.end()) {
if (!it->second.is_bool) return false;
if (it->second.value == value) return true;
bytes.replace(it->second.start, it->second.end - it->second.start, value);
} else {
bytes.insert(root.end - 1, std::string(root.object.empty() ? "" : ",") +
"\"advanced_debug\":" + value);
"\"" + std::string(key) + "\":" + value);
}
// The native reader rejects files >=4097 bytes, even if the JSON is valid.
if (bytes.size() > 4096) return false;
@@ -281,6 +285,13 @@ bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexce
return false;
}
}
} // namespace
bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept {
return save_bool_option(path, "advanced_debug", enabled);
}
bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept {
return save_bool_option(path, "auto_insert", enabled);
}
std::string resolve_font(const std::string& assets, const std::string& requested) {
const auto bundled = std::filesystem::path(assets) / "fonts/Inconsolata-Regular.ttf";
const std::array<std::filesystem::path, 4> candidates{requested, bundled,
+3 -1
View File
@@ -19,15 +19,17 @@ struct Config {
// Requests OpenVR's experimental global action priority; SteamVR must allow it too.
bool experimental_input_priority = false;
bool advanced_debug = false; // opt-in full diagnostic logging; never raw audio recording
bool auto_insert = false; // opt-in; runtime also requires uninterrupted verified Xwayland focus
WristPlacement wrist;
// OpenVR action name -> physical Frame controller input path; empty disables it.
std::map<std::string, std::string> buttons;
};
std::filesystem::path default_config_path();
Config load_config(const std::filesystem::path& path);
// Update only advanced_debug in an existing valid config; false on invalid/unwritable paths.
// Update only the selected boolean in an existing valid config; false on invalid/unwritable paths.
// Other user customizations and formatting are retained; creates a minimal config if absent.
bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept;
bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept;
std::string resolve_font(const std::string& assets, const std::string& requested);
// No writes or OpenVR access when no custom button mappings are specified.
// When customized, build a generated manifest and bindings under XDG cache.
+183
View File
@@ -0,0 +1,183 @@
#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;
~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) ||
!property_window(gamescope_atom, XCB_ATOM_CARDINAL, gamescope) ||
!focus(actual) || active != gamescope || active != actual || !keys_up())
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 (type == XCB_PROPERTY_NOTIFY) {
const auto* e = reinterpret_cast<xcb_property_notify_event_t*>(event);
if (e->window == root && (e->atom == active_atom || e->atom == gamescope_atom)) bad = true;
} else if (type == XCB_FOCUS_OUT) {
const auto* e = reinterpret_cast<xcb_focus_out_event_t*>(event);
if (e->event == target) bad = true;
} else if (type == XCB_DESTROY_NOTIFY) {
const auto* e = reinterpret_cast<xcb_destroy_notify_event_t*>(event);
if (e->window == target) bad = true;
} 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;
}
std::free(event);
if (bad) return false;
}
return !failed();
}
bool check() {
if (!armed || failed() || !drain_events()) { dead = true; return false; }
xcb_window_t current = XCB_WINDOW_NONE;
if (!snapshot(current) || current != target || !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(); }
void FocusGuard::invalidate() { impl_->dead = true; impl_->armed = false; }
} // namespace frameyap
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <memory>
namespace frameyap {
// One-shot observer for an explicitly armed Xwayland focus target. A guard may
// never be re-armed after arm() has been attempted or invalidate() is called.
class FocusGuard {
public:
FocusGuard();
~FocusGuard();
FocusGuard(const FocusGuard&) = delete;
FocusGuard& operator=(const FocusGuard&) = delete;
FocusGuard(FocusGuard&&) = delete;
FocusGuard& operator=(FocusGuard&&) = delete;
bool arm();
bool valid();
void invalidate();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace frameyap
+3 -2
View File
@@ -22,7 +22,7 @@ void help() {
" --check-overlay --assets DIR [--font FILE] [--mount MODE] (5s, no mic/input)\n"
" --check-controls --assets DIR [--font FILE] [--mount MODE] (30s, gestures only)\n\n"
"Mount: world (first-launch default), left-wrist, right-wrist, head.\n"
"Settings save the mount and opt-in Lasers anytime mode (may affect games).\n"
"Settings save the mount, opt-in Lasers anytime mode and Auto insert (off by default).\n"
"--mount overrides placement for this launch. --head is an alias.\n"
"Theme, font and Frame button mappings: $XDG_CONFIG_HOME/frameyap/config.json\n"
"(or ~/.config/frameyap/config.json). CLI --font overrides config; missing fonts\n"
@@ -33,7 +33,8 @@ void help() {
"Grip gestures are remappable but may be unavailable in the dashboard.\n"
"Right B: cancel; A: insert + space; Y: insert pending text + Enter.\n"
"Left grip: double-tap for the same explicit Enter action when active.\n"
"Review first: Insert approves current focus; never automatic Enter.\n"
"Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n"
"Insert approves current focus; Enter is never automatic.\n"
"No device access unless an explicit runtime/check/registration mode is used.\n";
#ifndef FRAMEYAP_NATIVE
std::cout << "This offline build has no hardware backends; enable FRAMEYAP_NATIVE to run.\n";
+13 -3
View File
@@ -74,7 +74,7 @@ struct Overlay::Impl {
Config config;
PanelSurface surface;
Panel panel;
bool save_failed = false, debug_save_failed = false;
bool save_failed = false, debug_save_failed = false, auto_save_failed = false;
bool world_ready = false, placed = false, has_texture = false, shown = false;
float size_scale = 1.f;
bool size_changed = false;
@@ -153,6 +153,7 @@ struct Overlay::Impl {
}
surface.set_lasers_anytime(lasers_anytime);
surface.set_advanced_debug(config.advanced_debug);
surface.set_auto_insert(config.auto_insert);
overlay_check(overlay->SetOverlayFlag(handle, vr::VROverlayFlags_VisibleInDashboard, true), overlay, "VisibleInDashboard");
vr::HmdVector2_t mouse_scale{{float(W), float(H)}};
overlay_check(overlay->SetOverlayMouseScale(handle, &mouse_scale), overlay, "SetOverlayMouseScale");
@@ -192,7 +193,8 @@ struct Overlay::Impl {
}
if (effective == Mount::World && applied_mount && *applied_mount != Mount::World)
world_ready = false; // a fresh world fallback near the wearer, not an old room location
std::string note = debug_save_failed ? "Debug preference not saved; using it only for this session." :
std::string note = auto_save_failed ? "Auto insert preference not saved; using it only for this session." :
debug_save_failed ? "Debug preference not saved; using it only for this session." :
laser_change_failed ? "SteamVR declined the laser mode change." :
save_failed ? "Preference could not be saved; using it for this session." : "";
if (effective != mount) note = save_failed ? "Wrist untracked; world fallback. Preference not saved." :
@@ -359,8 +361,15 @@ struct Overlay::Impl {
last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
if (event.data.mouse.button == vr::VRMouseButton_Left) {
auto event_result = surface.pointer_up(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y);
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug) ++pointer_actions;
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug || event_result.auto_insert) ++pointer_actions;
if (event_result.action) result.push_back(*event_result.action);
if (event_result.auto_insert) {
config.auto_insert = *event_result.auto_insert;
auto_save_failed = persist_mount && !save_auto_insert(default_config_path(), config.auto_insert);
surface.set_auto_insert(config.auto_insert);
reset_input(result); // setting change invalidates held actions
return result;
}
if (event_result.advanced_debug) {
config.advanced_debug = *event_result.advanced_debug;
debug_save_failed = persist_mount && !save_advanced_debug(default_config_path(), config.advanced_debug);
@@ -452,6 +461,7 @@ Overlay::~Overlay() = default;
std::vector<UiAction> Overlay::poll() { return impl_->poll(); }
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; }
bool Overlay::auto_insert() const { return impl_->config.auto_insert; }
std::string Overlay::controls_status() {
// Compare the same actions across modes, before and after our pose/role gate.
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
+1
View File
@@ -25,6 +25,7 @@ public:
std::vector<UiAction> poll();
void draw(const Panel& panel);
bool advanced_debug() const;
bool auto_insert() const;
std::string controls_status(); // diagnostic only, no input delivery
std::string pointer_status() const; // diagnostic counters, no input delivery
private:
+22 -11
View File
@@ -28,10 +28,10 @@ struct Rect {
}
};
enum class Control { Review, Settings, Bindings, Prev, Next, Record, Cancel, Insert, Enter, Quit,
World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug };
World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert };
enum class Tab { Review, Settings };
struct Button { Rect r; Control id; const char* label; };
constexpr std::array<Button, 17> buttons{{
constexpr std::array<Button, 18> buttons{{
{{32, 138, 180, 46}, Control::Review, "Review"},
{{226, 138, 180, 46}, Control::Settings, "Settings"},
{{420, 138, 180, 46}, Control::Bindings, "Bindings"},
@@ -48,7 +48,8 @@ constexpr std::array<Button, 17> buttons{{
{{514, 308, 454, 58}, Control::Right, "Right wrist"},
{{32, 394, 300, 50}, Control::Recenter, "Recenter in front"},
{{514, 394, 454, 50}, Control::LasersAnytime, "Lasers anytime"},
{{32, 452, 936, 48}, Control::AdvancedDebug, "Advanced debugging (full logs)"},
{{32, 452, 454, 48}, Control::AutoInsert, "Auto insert"},
{{514, 452, 454, 48}, Control::AdvancedDebug, "Advanced debug"},
}};
std::optional<UiAction> action(Control c) {
switch (c) {
@@ -107,7 +108,7 @@ struct PanelSurface::Impl {
Theme theme;
Color background, card, ink, muted, cyan, pink;
Tab tab = Tab::Review;
bool dirty = true, lasers_anytime = false, advanced_debug = false;
bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false;
std::string placement_note, binding_note;
std::array<int, 2> pressed{{-1, -1}};
int resize_cursor = -1;
@@ -248,7 +249,7 @@ struct PanelSurface::Impl {
bool visible(Control c) const {
if (c == Control::Prev || c == Control::Next) return tab == Tab::Review;
if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime ||
c == Control::AdvancedDebug) return tab == Tab::Settings;
c == Control::AdvancedDebug || c == Control::AutoInsert) return tab == Tab::Settings;
return true;
}
bool enabled(Control c) const {
@@ -284,7 +285,7 @@ struct PanelSurface::Impl {
rect({0, 0, W, H}, background);
frame();
text("FrameYap", 32, 61, 40, ink, 300);
text("ON-DEVICE / REVIEW FIRST", 280, 58, 22, muted, 720);
text(auto_insert ? "ON-DEVICE / AUTO INSERT OPT-IN" : "ON-DEVICE / REVIEW FIRST", 280, 58, 22, muted, 720);
text(mount_label(mount), 756, 58, 24, cyan, 968);
rounded({32, 78, 936, 48}, 13, mix(background, card, .7f),
panel.recording ? mix(card, pink, .36f) : mix(card, cyan, .22f),
@@ -307,8 +308,8 @@ struct PanelSurface::Impl {
if (!binding_note.empty()) text(binding_note, 32, 203, 20, muted, 968);
} else {
text("MOUNT AND INTERACTION", 32, 224, 22, muted, 968);
text("Full logs may contain speech/text/paths. No saved audio clips.", 32, 520, 20, pink, 968);
text(placement_note.empty() ? "Toggle restarts worker; cancels current work. Lasers may affect games." : placement_note,
text("Auto insert requires stable X focus; never Enter. Debug logs may contain speech.", 32, 520, 20, pink, 968);
text(placement_note.empty() ? "Debug restarts worker; lasers may affect games. No saved audio clips." : placement_note,
32, 540, 20, muted, 968);
}
rect({32, 550, 936, 1}, mix(card, cyan, .17f));
@@ -320,7 +321,8 @@ struct PanelSurface::Impl {
(b.id == Control::Settings && tab == Tab::Settings) ||
(mounting(b.id) && *mounting(b.id) == mount) ||
(b.id == Control::LasersAnytime && lasers_anytime) ||
(b.id == Control::AdvancedDebug && advanced_debug);
(b.id == Control::AdvancedDebug && advanced_debug) ||
(b.id == Control::AutoInsert && auto_insert);
const Color fill = !on ? mix(background, card, .40f) :
selected ? mix(card, cyan, .14f) : card;
const Color accent = b.id == Control::Record && panel.recording ? pink : cyan;
@@ -332,8 +334,9 @@ struct PanelSurface::Impl {
const auto label = b.id == Control::Record && panel.recording ? "Stop" : b.label;
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, 27, on ? ink : mix(background, muted, .48f), b.r.x + b.r.w - 8);
if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12);
if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug) {
bool active = b.id == Control::LasersAnytime ? lasers_anytime : advanced_debug;
if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug || b.id == Control::AutoInsert) {
bool active = b.id == Control::LasersAnytime ? lasers_anytime :
b.id == Control::AutoInsert ? auto_insert : advanced_debug;
text(active ? "ON" : "OFF", b.r.x + b.r.w - 66, b.r.y + b.r.h / 2 + 9, 22,
active ? cyan : muted, b.r.x + b.r.w - 12);
}
@@ -386,6 +389,7 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) {
else if (c == Control::Recenter) { result.recenter = true; impl_->reset(); }
else if (c == Control::LasersAnytime) result.lasers_anytime = !impl_->lasers_anytime;
else if (c == Control::AdvancedDebug) result.advanced_debug = !impl_->advanced_debug;
else if (c == Control::AutoInsert) result.auto_insert = !impl_->auto_insert;
else if (c == Control::Bindings) { result.open_bindings = true; impl_->reset(); }
else if (c == Control::Review || c == Control::Settings) {
impl_->tab = c == Control::Review ? Tab::Review : Tab::Settings;
@@ -416,4 +420,11 @@ void PanelSurface::set_advanced_debug(bool enabled) {
impl_->dirty = true;
}
}
void PanelSurface::set_auto_insert(bool enabled) {
if (impl_->auto_insert != enabled) {
impl_->auto_insert = enabled;
impl_->reset();
impl_->dirty = true;
}
}
} // namespace frameyap
+2
View File
@@ -12,6 +12,7 @@ struct SurfaceEvent {
std::optional<Mount> mount;
std::optional<bool> lasers_anytime;
std::optional<bool> advanced_debug;
std::optional<bool> auto_insert;
bool recenter = false;
bool open_bindings = false;
};
@@ -34,6 +35,7 @@ public:
void set_placement_note(std::string note);
void set_lasers_anytime(bool enabled);
void set_advanced_debug(bool enabled);
void set_auto_insert(bool enabled);
void set_binding_note(std::string note);
bool available(UiAction action) const;
private:
+48 -7
View File
@@ -5,6 +5,7 @@
#include "text_input.hpp"
#include "worker.hpp"
#include "instance_lock.hpp"
#include "focus_guard.hpp"
#include <algorithm>
#include <chrono>
#include <csignal>
@@ -56,6 +57,8 @@ int run(const Options& options) {
std::string detail = "Review mode. Other apps may also hear your mic. Enter is explicit.";
bool quit = false;
bool advanced_debug = overlay.advanced_debug();
bool auto_insert = overlay.auto_insert();
std::unique_ptr<FocusGuard> armed_focus;
const DeliveryFactory acquire = [&]() -> std::unique_ptr<DeliveryLease> {
auto input = std::make_unique<NativeDeliveryLease>(options.socket);
if (interrupted) throw std::runtime_error("Input cancelled before delivery");
@@ -78,7 +81,7 @@ int run(const Options& options) {
}
};
auto warm = [&] {
audio.close(); worker.stop(); session = Session{};
audio.close(); worker.stop(); session = Session{}; armed_focus.reset();
worker.start(options.python, options.worker, options.model, options.threads, advanced_debug);
detail = "Loading local model; microphone closed. Record again when Ready.";
};
@@ -87,8 +90,9 @@ int run(const Options& options) {
auto pcm = audio.finish();
if (session.finish(pcm.size())) {
worker.submit(session.id(), pcm);
detail = "Release complete. No text is inserted automatically.";
} else detail = "Short tap discarded (minimum 200ms).";
detail = armed_focus ? "Release complete; checking stable focus before auto insert." :
"Release complete. Review before inserting.";
} else { armed_focus.reset(); detail = "Short tap discarded (minimum 200ms)."; }
std::fill(pcm.begin(), pcm.end(), 0.0f);
};
auto start_record = [&] {
@@ -97,12 +101,22 @@ int run(const Options& options) {
if (!audio.open()) audio.prepare(); // recovery only; normal PTT never opens the device
if (session.state() == State::Error) session.cancel();
if (!session.record()) return;
armed_focus.reset();
if (auto_insert) {
auto candidate = std::make_unique<FocusGuard>();
if (candidate->arm()) armed_focus = std::move(candidate);
}
audio.start();
detail = "Release to finish. Cancel discards. Maximum 20 seconds.";
detail = auto_insert && !armed_focus ? "Focus unverified; recording will require manual Insert." :
"Release to finish. Cancel discards. Maximum 20 seconds.";
};
try { warm(); }
catch (const std::exception& e) { session.fail(); detail = e.what(); }
while (!quit && !interrupted) {
if (armed_focus && !armed_focus->valid()) {
armed_focus.reset();
detail = "Focus changed or became uncertain; transcript will require manual Insert.";
}
try {
if (auto reply = worker.poll()) {
if (reply->id == session.id() && session.state() == State::Transcribing) {
@@ -115,13 +129,33 @@ int run(const Options& options) {
std::cerr << "FrameYap worker: " << reply->error << '\n';
} else {
session.reply(reply->id, reply->text);
detail = session.text().empty() ? "No speech recognized; try again." : "Focus your destination, then Insert. Cancel discards.";
detail = session.text().empty() ? "No speech recognized; try again." :
"Focus your destination, then Insert. Cancel discards.";
if (session.state() == State::Review && auto_insert && armed_focus && armed_focus->valid()) {
// The exclusive IME lease can take time to acquire.
// Recheck *after* acquisition and before consuming the review.
const DeliveryFactory guarded = [&]() -> std::unique_ptr<DeliveryLease> {
auto lease = acquire();
if (!armed_focus || !armed_focus->valid())
throw std::runtime_error("Focus changed during input authorization");
return lease;
};
try {
const auto outcome = deliver_insert(session, guarded);
delivery_detail(outcome, false);
if (outcome == DeliveryResult::TextQueued)
detail = "Auto insert queued text + space to verified focus; never Enter. Not a delivery receipt.";
} catch (const std::exception&) {
detail = "Auto insert blocked; text kept for manual review. Check focus and Insert.";
}
}
armed_focus.reset();
}
}
}
if (worker.ready()) session.ready();
} catch (const std::exception& e) {
audio.close(); worker.stop();
armed_focus.reset(); audio.close(); worker.stop();
if (session.state() != State::Review && session.state() != State::Queued) session.fail();
detail = e.what(); // Preserve an already-correlated preview if the worker dies.
}
@@ -134,6 +168,7 @@ int run(const Options& options) {
} catch (const std::exception& e) {
// A microphone failure is not a model failure.
audio.close(); session.fail(); detail = e.what();
armed_focus.reset();
}
// Drawing precedes input polling: Enter is disabled until Ready is visible.
const auto status = session.state() == State::Error && worker.ready()
@@ -145,6 +180,12 @@ int run(const Options& options) {
if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s";
overlay.draw(panel);
auto actions = overlay.poll();
if (auto_insert != overlay.auto_insert()) {
auto_insert = overlay.auto_insert();
armed_focus.reset(); // a toggle never retroactively authorizes a capture
detail = auto_insert ? "Auto insert enabled for future clips only when X focus stays verified. Never Enter." :
"Auto insert disabled; review before Insert.";
}
if (advanced_debug != overlay.advanced_debug()) {
advanced_debug = overlay.advanced_debug();
// Consent changes take effect before any more work or delivery. The
@@ -165,7 +206,7 @@ int run(const Options& options) {
case UiAction::EndRecord: stop_record(); break;
case UiAction::Cancel: {
auto state = session.state();
audio.cancel(); session.cancel(); detail = "Discarded. Idle microphone samples are discarded.";
armed_focus.reset(); audio.cancel(); session.cancel(); detail = "Discarded. Idle microphone samples are discarded.";
if (state == State::Warming || state == State::Transcribing) {
audio.close(); worker.stop(); session.fail(); detail = "Cancelled. Record to reload local worker.";
} else if (state == State::Error && !worker.ready()) {
+14
View File
@@ -29,12 +29,14 @@ int main(int argc, char** argv) {
assert(load_config(path).buttons.empty());
assert(!load_config(path).experimental_input_priority);
assert(!load_config(path).advanced_debug);
assert(!load_config(path).auto_insert);
assert(load_config(path).wrist.width == .30f);
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
assert(example.buttons.at("ptt") == "/user/hand/right/input/x");
assert(example.font.empty());
assert(!example.experimental_input_priority);
assert(!example.advanced_debug);
assert(!example.auto_insert);
assert(example.wrist.y == .18f && example.wrist.z == .089f);
std::filesystem::create_directories(path.parent_path());
assert(save_advanced_debug(path, true));
@@ -42,6 +44,10 @@ int main(int argc, char** argv) {
assert(get(path).find("\"advanced_debug\":true") != std::string::npos);
assert(save_advanced_debug(path, false));
assert(!load_config(path).advanced_debug);
assert(save_auto_insert(path, true));
assert(load_config(path).auto_insert);
assert(save_auto_insert(path, false));
assert(!load_config(path).auto_insert);
for (const auto* invalid : {R"({"advanced_debug":"true"})", R"({"advanced_debug":0})",
R"({"advanced_debug":null})", R"({"advanced_debug":[]})"}) {
put(path, invalid);
@@ -69,6 +75,7 @@ int main(int argc, char** argv) {
auto link = dir / "linked-config";
std::filesystem::create_symlink(path, link);
assert(!save_advanced_debug(link, false));
assert(!save_auto_insert(link, true));
assert(get(path) == before);
std::filesystem::remove(link);
put(path, R"({"font":")" + std::string(4090, 'x') + R"("})");
@@ -79,6 +86,13 @@ int main(int argc, char** argv) {
assert(experimental.experimental_input_priority && experimental.advanced_debug);
// A priority request must preserve the user's existing action manifest/bindings.
assert(action_manifest(argv[1], experimental) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json"));
put(path, R"({"auto_insert":"on"})");
fails([&] { load_config(path); });
assert(!save_auto_insert(path, true));
put(path, R"({"auto_insert":false,"font":"kept"})");
assert(save_auto_insert(path, true));
assert(load_config(path).auto_insert);
assert(get(path) == R"({"auto_insert":true,"font":"kept"})");
put(path, R"({"input_priority":"normal"})");
assert(!load_config(path).experimental_input_priority);
for (const auto* invalid : {R"({"input_priority":true})", R"({"input_priority":16777216})",
+12
View File
@@ -75,6 +75,18 @@ void delivery_checks() {
CHECK(deliver_enter(s, fake.factory()) == DeliveryResult::EnterQueued);
CHECK((fake.events == std::vector<std::string>{"text:preserve ", "enter"}));
}
{
auto s = review("focus lost during lease"); FakeDelivery fake;
auto acquire = fake.factory();
const DeliveryFactory invalidated_after_acquisition = [&]() -> std::unique_ptr<DeliveryLease> {
auto lease = acquire();
throw std::runtime_error("focus changed while connecting to IME");
};
try { (void)deliver_insert(s, invalidated_after_acquisition); CHECK(false); }
catch (const std::runtime_error&) {}
CHECK(s.state() == State::Review && s.text() == "focus lost during lease");
CHECK(fake.events.empty() && fake.active_leases == 0);
}
{
auto s = review("uncertain"); FakeDelivery fake; fake.fail_text = true;
CHECK(deliver_enter(s, fake.factory()) == DeliveryResult::TextUncertain);
+53
View File
@@ -0,0 +1,53 @@
#include "focus_guard.hpp"
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string>
using frameyap::FocusGuard;
#define CHECK(condition) do { if (!(condition)) throw std::runtime_error( \
std::string("line ") + std::to_string(__LINE__) + ": " #condition); } while (false)
class DisplaySetting {
public:
DisplaySetting() {
if (const char* current = std::getenv("DISPLAY")) {
previous_ = current;
had_previous_ = true;
}
if (setenv("DISPLAY", "frameyap-no-such-display:9876", 1) != 0)
throw std::runtime_error("could not set isolated DISPLAY");
}
~DisplaySetting() {
if (had_previous_) setenv("DISPLAY", previous_.c_str(), 1);
else unsetenv("DISPLAY");
}
DisplaySetting(const DisplaySetting&) = delete;
DisplaySetting& operator=(const DisplaySetting&) = delete;
private:
std::string previous_;
bool had_previous_ = false;
};
int main() {
try {
DisplaySetting isolated_display;
FocusGuard guard; // Construction must not connect to X.
CHECK(!guard.valid());
CHECK(!guard.arm()); // No server: fail closed.
CHECK(!guard.valid());
CHECK(!guard.arm()); // No reconnect/rearm attempt.
guard.invalidate();
CHECK(!guard.valid());
FocusGuard explicitly_invalidated;
explicitly_invalidated.invalidate();
CHECK(!explicitly_invalidated.arm());
CHECK(!explicitly_invalidated.valid());
std::cout << "focus_guard fail-closed policy checks passed\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "focus_guard_test: " << e.what() << '\n';
return 1;
}
}
+68
View File
@@ -0,0 +1,68 @@
#include "focus_guard.hpp"
#include <xcb/xcb.h>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <memory>
using namespace frameyap;
namespace {
struct Free { void operator()(void* p) const { std::free(p); } };
template<class T> using Reply = std::unique_ptr<T, Free>;
xcb_atom_t atom(xcb_connection_t* c, const char* name) {
auto cookie = xcb_intern_atom(c, 0, std::strlen(name), name);
Reply<xcb_intern_atom_reply_t> reply(xcb_intern_atom_reply(c, cookie, nullptr));
assert(reply && reply->atom);
return reply->atom;
}
void focus(xcb_connection_t* c, xcb_window_t root, xcb_window_t window,
xcb_atom_t active, xcb_atom_t gamescope) {
xcb_change_property(c, XCB_PROP_MODE_REPLACE, root, active, XCB_ATOM_WINDOW, 32, 1, &window);
xcb_change_property(c, XCB_PROP_MODE_REPLACE, root, gamescope, XCB_ATOM_CARDINAL, 32, 1, &window);
xcb_set_input_focus(c, XCB_INPUT_FOCUS_POINTER_ROOT, window, XCB_CURRENT_TIME);
auto cookie = xcb_get_input_focus(c);
Reply<xcb_get_input_focus_reply_t> reply(xcb_get_input_focus_reply(c, cookie, nullptr));
assert(reply && reply->focus == window);
}
}
int main() {
int index = 0;
xcb_connection_t* c = xcb_connect(nullptr, &index);
assert(c && !xcb_connection_has_error(c));
auto screens = xcb_setup_roots_iterator(xcb_get_setup(c));
for (int i = 0; i < index; ++i) xcb_screen_next(&screens);
assert(screens.rem);
const auto root = screens.data->root;
const auto active = atom(c, "_NET_ACTIVE_WINDOW"), gamescope = atom(c, "GAMESCOPE_FOCUSED_WINDOW");
const auto a = xcb_generate_id(c), b = xcb_generate_id(c);
xcb_create_window(c, XCB_COPY_FROM_PARENT, a, root, 0, 0, 160, 100, 0,
XCB_WINDOW_CLASS_INPUT_OUTPUT, screens.data->root_visual, 0, nullptr);
xcb_create_window(c, XCB_COPY_FROM_PARENT, b, root, 180, 0, 160, 100, 0,
XCB_WINDOW_CLASS_INPUT_OUTPUT, screens.data->root_visual, 0, nullptr);
xcb_map_window(c, a); xcb_map_window(c, b);
focus(c, root, a, active, gamescope);
FocusGuard stable;
assert(stable.arm() && stable.valid());
focus(c, root, b, active, gamescope);
focus(c, root, a, active, gamescope);
assert(!stable.valid()); // loss/regain of the same ID cannot authorize delivery
assert(!stable.arm());
FocusGuard new_capture;
assert(new_capture.arm() && new_capture.valid());
xcb_change_property(c, XCB_PROP_MODE_REPLACE, root, active, XCB_ATOM_WINDOW, 32, 1, &b);
auto cookie = xcb_get_input_focus(c);
Reply<xcb_get_input_focus_reply_t> reply(xcb_get_input_focus_reply(c, cookie, nullptr));
assert(reply);
assert(!new_capture.valid()); // compositor/X disagreement fails closed
focus(c, root, a, active, gamescope);
FocusGuard destroyed;
assert(destroyed.arm());
xcb_destroy_window(c, a);
cookie = xcb_get_input_focus(c);
reply.reset(xcb_get_input_focus_reply(c, cookie, nullptr));
assert(reply);
assert(!destroyed.valid());
xcb_disconnect(c);
std::cout << "focus guard XCB transitions passed\n";
}
+9 -1
View File
@@ -12,7 +12,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0)
return surface.pointer_up(cursor, x, y);
}
void no_action(const SurfaceEvent& event) {
assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug &&
assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert &&
!event.recenter && !event.open_bindings);
}
void snapshot(PanelSurface& surface, const std::string& path) {
@@ -126,6 +126,13 @@ int main(int argc, char** argv) {
laser = click(surface, 680, 420);
assert(laser.lasers_anytime == false && !laser.action && !laser.mount);
surface.set_lasers_anytime(false); assert(surface.render(p));
auto automatic = click(surface, 200, 474);
assert(automatic.auto_insert == true && !automatic.action);
assert(!surface.render(p)); // request alone has no effect
surface.set_auto_insert(true); assert(surface.render(p));
automatic = click(surface, 200, 474);
assert(automatic.auto_insert == false);
surface.set_auto_insert(false); assert(surface.render(p));
auto debug = click(surface, 680, 474);
assert(debug.advanced_debug == true && !debug.action && !debug.mount && !debug.lasers_anytime);
assert(!surface.render(p)); // an event is only a request; caller sets the accepted value
@@ -151,6 +158,7 @@ int main(int argc, char** argv) {
no_action(click(surface, 680, 260)); // mount controls not active on Review
no_action(click(surface, 680, 420)); // laser toggle only exists on Settings
no_action(click(surface, 680, 474)); // debug toggle only exists on Settings
no_action(click(surface, 200, 474)); // auto insert only exists on Settings
// Bindings opens SteamVR directly, from either tab, without replacing review.
surface.pointer_down(1, 480, 610);
+16
View File
@@ -74,6 +74,7 @@ class InstallTests(unittest.TestCase):
config = self.home / ".config/frameyap/config.json"
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS)
self.assertIs(json.loads(config.read_text())["advanced_debug"], False)
self.assertIs(json.loads(config.read_text())["auto_insert"], False)
self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text())
self.assertTrue(os.access(root / "versions/v1/runtime/bin/helper", os.X_OK))
@@ -127,12 +128,14 @@ class InstallTests(unittest.TestCase):
self.assertEqual(fixed["buttons"]["cancel"], "/user/hand/right/input/b")
self.assertEqual(fixed["input_priority"], "normal")
self.assertIs(fixed["advanced_debug"], False)
self.assertIs(fixed["auto_insert"], False)
self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"])
backups = list(config.parent.glob("config.json.backup-*"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), original)
fixed["input_priority"] = "experimental"
fixed["advanced_debug"] = True
fixed["auto_insert"] = True
fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades
fixed["wrist"]["y"] = 0.2
compact = json.dumps(fixed, separators=(",", ":")).encode()
@@ -140,6 +143,7 @@ class InstallTests(unittest.TestCase):
self.install("v1", archive, digest)
self.assertEqual(config.read_bytes(), compact)
self.assertIs(json.loads(config.read_text())["advanced_debug"], True)
self.assertIs(json.loads(config.read_text())["auto_insert"], True)
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
fixed["advanced_debug"] = False
compact_off = json.dumps(fixed, separators=(",", ":")).encode()
@@ -156,6 +160,7 @@ class InstallTests(unittest.TestCase):
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
self.assertEqual(fixed["input_priority"], "normal")
self.assertIs(fixed["advanced_debug"], False)
self.assertIs(fixed["auto_insert"], False)
self.assertEqual(fixed["wrist"]["x"], 0.04)
self.assertEqual(fixed["wrist"]["y"], 0.18)
self.assertEqual(fixed["wrist"]["width"], 0.30)
@@ -193,6 +198,17 @@ class InstallTests(unittest.TestCase):
self.assertEqual(json.loads(config.read_text())["font"], "/custom/font.ttf")
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
def test_auto_insert_boolean_repair_backs_up_invalid_values(self):
archive, digest = self.package("v1")
config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True)
for invalid in ("true", 1, None, [], {}):
original = json.dumps({"auto_insert": invalid, "font": "/custom/font.ttf"}).encode()
config.write_bytes(original)
self.install("v1", archive, digest)
self.assertIs(json.loads(config.read_text())["auto_insert"], False)
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
def test_digest_and_same_version_mismatch_leave_previous(self):
a, h = self.package("v1")
self.install("v1", a, h)