mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 01:00:04 +02:00
Add opt-in fail-closed Xwayland auto insert
This commit is contained in:
1 parent
19a3db7667
commit
c5e925cb48
27 files changed
+588
-45
No files matched your search
+15
-2
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] = {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user