From c5e925cb48965b2fa0c4927010f38efb5c2cba4a Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 16:12:33 -0400 Subject: [PATCH] Add opt-in fail-closed Xwayland auto insert --- CMakeLists.txt | 17 ++- README.md | 11 +- assets/config.example.json | 1 + docs/design.md | 12 +- docs/evidence/focus-probe-2026-09-24.md | 29 ++++ docs/overlay.md | 22 ++- docs/packaging.md | 4 +- docs/poc.md | 12 +- docs/third-party.md | 3 + install.sh | 3 + scripts/install_payload.py | 3 + src/config.cpp | 17 ++- src/config.hpp | 4 +- src/focus_guard.cpp | 183 ++++++++++++++++++++++++ src/focus_guard.hpp | 27 ++++ src/main.cpp | 5 +- src/overlay.cpp | 16 ++- src/overlay.hpp | 1 + src/panel_surface.cpp | 33 +++-- src/panel_surface.hpp | 2 + src/runtime.cpp | 55 ++++++- tests/config_test.cpp | 14 ++ tests/core_test.cpp | 12 ++ tests/focus_guard_test.cpp | 53 +++++++ tests/focus_guard_xcb_test.cpp | 68 +++++++++ tests/panel_test.cpp | 10 +- tests/test_installer.py | 16 +++ 27 files changed, 588 insertions(+), 45 deletions(-) create mode 100644 docs/evidence/focus-probe-2026-09-24.md create mode 100644 src/focus_guard.cpp create mode 100644 src/focus_guard.hpp create mode 100644 tests/focus_guard_test.cpp create mode 100644 tests/focus_guard_xcb_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 090a2e0..d231354 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 $) + 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) diff --git a/README.md b/README.md index dbd937a..1a472f3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/config.example.json b/assets/config.example.json index b94bc93..1137d3d 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -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", diff --git a/docs/design.md b/docs/design.md index 258605e..64e44be 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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, diff --git a/docs/evidence/focus-probe-2026-09-24.md b/docs/evidence/focus-probe-2026-09-24.md new file mode 100644 index 0000000..00c6ed6 --- /dev/null +++ b/docs/evidence/focus-probe-2026-09-24.md @@ -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. diff --git a/docs/overlay.md b/docs/overlay.md index c684f7b..eea2b0e 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -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 diff --git a/docs/packaging.md b/docs/packaging.md index 8bca119..23a2df1 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -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. diff --git a/docs/poc.md b/docs/poc.md index b06d45d..21444dd 100644 --- a/docs/poc.md +++ b/docs/poc.md @@ -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. diff --git a/docs/third-party.md b/docs/third-party.md index 3ffd364..d20a8ca 100644 --- a/docs/third-party.md +++ b/docs/third-party.md @@ -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; diff --git a/install.sh b/install.sh index c236db6..7f6ae4b 100755 --- a/install.sh +++ b/install.sh @@ -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"] = {} diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 8b4f7c5..b6c9ad5 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -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"] = {} diff --git a/src/config.cpp b/src/config.cpp index 244a3d6..7a215e6 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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 candidates{requested, bundled, diff --git a/src/config.hpp b/src/config.hpp index d97b0d8..8272c69 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -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 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. diff --git a/src/focus_guard.cpp b/src/focus_guard.cpp new file mode 100644 index 0000000..03f138b --- /dev/null +++ b/src/focus_guard.cpp @@ -0,0 +1,183 @@ +#include "focus_guard.hpp" + +#include + +#include +#include +#include + +namespace frameyap { +namespace { +struct ReplyDeleter { void operator()(void* p) const { std::free(p); } }; +template using Reply = std::unique_ptr; +} + +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(std::strlen(name)), name); + xcb_generic_error_t* error = nullptr; + Reply reply(xcb_intern_atom_reply(connection, cookie, &error)); + Reply 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 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 reply(xcb_get_property_reply(connection, cookie, &error)); + Reply 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(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 reply(xcb_get_input_focus_reply(connection, cookie, &error)); + Reply 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 reply(xcb_query_keymap_reply(connection, cookie, &error)); + Reply 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(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(event); + if (e->event == target) bad = true; + } else if (type == XCB_DESTROY_NOTIFY) { + const auto* e = reinterpret_cast(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(event)->parent; break; + case XCB_UNMAP_NOTIFY: window = reinterpret_cast(event)->window; break; + case XCB_MAP_NOTIFY: window = reinterpret_cast(event)->window; break; + case XCB_MAP_REQUEST: window = reinterpret_cast(event)->window; break; + case XCB_REPARENT_NOTIFY: window = reinterpret_cast(event)->window; break; + case XCB_CONFIGURE_NOTIFY: window = reinterpret_cast(event)->window; break; + case XCB_CONFIGURE_REQUEST: window = reinterpret_cast(event)->window; break; + case XCB_GRAVITY_NOTIFY: window = reinterpret_cast(event)->window; break; + case XCB_RESIZE_REQUEST: window = reinterpret_cast(event)->window; break; + case XCB_CIRCULATE_NOTIFY: window = reinterpret_cast(event)->window; break; + case XCB_CIRCULATE_REQUEST: window = reinterpret_cast(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()) {} +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 diff --git a/src/focus_guard.hpp b/src/focus_guard.hpp new file mode 100644 index 0000000..8303921 --- /dev/null +++ b/src/focus_guard.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +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_; +}; + +} // namespace frameyap diff --git a/src/main.cpp b/src/main.cpp index dae3fa7..3d22629 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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"; diff --git a/src/overlay.cpp b/src/overlay.cpp index fe6290a..9afdeea 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -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 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. diff --git a/src/overlay.hpp b/src/overlay.hpp index c1e7dbe..db45029 100644 --- a/src/overlay.hpp +++ b/src/overlay.hpp @@ -25,6 +25,7 @@ public: std::vector 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: diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index faf23d6..3fc6692 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -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 buttons{{ +constexpr std::array 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 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 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 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 diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index 8ff6ecb..a682f19 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -12,6 +12,7 @@ struct SurfaceEvent { std::optional mount; std::optional lasers_anytime; std::optional advanced_debug; + std::optional 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: diff --git a/src/runtime.cpp b/src/runtime.cpp index c8dbfb6..65fbbeb 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -5,6 +5,7 @@ #include "text_input.hpp" #include "worker.hpp" #include "instance_lock.hpp" +#include "focus_guard.hpp" #include #include #include @@ -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 armed_focus; const DeliveryFactory acquire = [&]() -> std::unique_ptr { auto input = std::make_unique(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(); + 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 { + 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()) { diff --git a/tests/config_test.cpp b/tests/config_test.cpp index 61b404c..def3fa4 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -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})", diff --git a/tests/core_test.cpp b/tests/core_test.cpp index d3c4028..48735ea 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -75,6 +75,18 @@ void delivery_checks() { CHECK(deliver_enter(s, fake.factory()) == DeliveryResult::EnterQueued); CHECK((fake.events == std::vector{"text:preserve ", "enter"})); } + { + auto s = review("focus lost during lease"); FakeDelivery fake; + auto acquire = fake.factory(); + const DeliveryFactory invalidated_after_acquisition = [&]() -> std::unique_ptr { + 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); diff --git a/tests/focus_guard_test.cpp b/tests/focus_guard_test.cpp new file mode 100644 index 0000000..2efaa04 --- /dev/null +++ b/tests/focus_guard_test.cpp @@ -0,0 +1,53 @@ +#include "focus_guard.hpp" + +#include +#include +#include +#include + +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; + } +} diff --git a/tests/focus_guard_xcb_test.cpp b/tests/focus_guard_xcb_test.cpp new file mode 100644 index 0000000..60b6207 --- /dev/null +++ b/tests/focus_guard_xcb_test.cpp @@ -0,0 +1,68 @@ +#include "focus_guard.hpp" +#include +#include +#include +#include +#include +#include + +using namespace frameyap; +namespace { +struct Free { void operator()(void* p) const { std::free(p); } }; +template using Reply = std::unique_ptr; +xcb_atom_t atom(xcb_connection_t* c, const char* name) { + auto cookie = xcb_intern_atom(c, 0, std::strlen(name), name); + Reply 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 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 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"; +} diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 405cee7..890ce0c 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -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); diff --git a/tests/test_installer.py b/tests/test_installer.py index 4b18d09..ff617cb 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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)