mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-04 22:00:03 +02:00
Wire native OpenVR voice-typing POC with safe review and grip controls
This commit is contained in:
1 parent
73dc4fda06
commit
d60d8d2f5d
21 files changed
+1929
-29
No files matched your search
+103
-13
@@ -1,19 +1,109 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(frame_dictation VERSION 0.1.0 LANGUAGES CXX)
|
||||
project(frameyap VERSION 0.1.0 LANGUAGES C CXX)
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
option(FRAMEYAP_NATIVE "Build opt-in OpenVR/SDL3/Gamescope runtime (no automatic downloads)" OFF)
|
||||
|
||||
add_executable(frame-dictation src/main.cpp)
|
||||
target_compile_features(frame-dictation PRIVATE cxx_std_20)
|
||||
set_target_properties(frame-dictation PROPERTIES CXX_EXTENSIONS OFF)
|
||||
target_compile_definitions(frame-dictation PRIVATE FRAME_DICTATION_VERSION="${PROJECT_VERSION}")
|
||||
add_library(frameyap_core src/core.cpp)
|
||||
target_include_directories(frameyap_core PUBLIC src)
|
||||
add_executable(frameyap src/main.cpp)
|
||||
target_link_libraries(frameyap PRIVATE frameyap_core)
|
||||
target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${PROJECT_VERSION}")
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
add_library(frameyap_worker src/worker.cpp)
|
||||
target_include_directories(frameyap_worker PUBLIC src)
|
||||
endif()
|
||||
if(FRAMEYAP_NATIVE)
|
||||
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
message(FATAL_ERROR "Native POC supports Linux only")
|
||||
endif()
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(SDL3 REQUIRED IMPORTED_TARGET sdl3>=3.2)
|
||||
# SDL's .pc can inject its producer-prefix RPATH. Let CMake manage build
|
||||
# RPATH and install only our relative path, never a developer environment.
|
||||
get_target_property(SDL3_LINK_OPTIONS PkgConfig::SDL3 INTERFACE_LINK_OPTIONS)
|
||||
if(SDL3_LINK_OPTIONS)
|
||||
list(FILTER SDL3_LINK_OPTIONS EXCLUDE REGEX "^-Wl,-rpath,")
|
||||
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(FREETYPE REQUIRED IMPORTED_TARGET freetype2)
|
||||
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)
|
||||
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$")
|
||||
set(OPENVR_PLATFORM linuxarm64)
|
||||
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64)$")
|
||||
set(OPENVR_PLATFORM linux64)
|
||||
else()
|
||||
message(FATAL_ERROR "Native POC requires Linux ARM64 or x86-64")
|
||||
endif()
|
||||
find_library(OPENVR_LIBRARY openvr_api HINTS "${OPENVR_ROOT}/lib/${OPENVR_PLATFORM}" "${OPENVR_ROOT}/bin/${OPENVR_PLATFORM}" REQUIRED)
|
||||
set(PROTOCOL "${CMAKE_CURRENT_SOURCE_DIR}/protocol/gamescope-input-method.xml")
|
||||
add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h"
|
||||
COMMAND "${WAYLAND_SCANNER}" client-header "${PROTOCOL}" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h"
|
||||
DEPENDS "${PROTOCOL}" VERBATIM)
|
||||
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/audio.cpp src/text_input.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 PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::FREETYPE "${OPENVR_LIBRARY}")
|
||||
target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1)
|
||||
set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib")
|
||||
endif()
|
||||
install(TARGETS frameyap RUNTIME DESTINATION bin)
|
||||
install(DIRECTORY assets/ DESTINATION assets)
|
||||
install(DIRECTORY python/frameyap DESTINATION python FILES_MATCHING PATTERN "*.py" PATTERN "__pycache__" EXCLUDE)
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
|
||||
add_test(NAME frame_dictation.cli
|
||||
COMMAND "${CMAKE_COMMAND}"
|
||||
"-DAPP=$<TARGET_FILE:frame-dictation>"
|
||||
"-DEXPECTED_VERSION=${PROJECT_VERSION}"
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/cli.cmake")
|
||||
add_test(NAME frame_dictation.install_preflight
|
||||
COMMAND sh "${CMAKE_CURRENT_SOURCE_DIR}/tests/install-preflight.sh"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/install-preflight.sh")
|
||||
add_test(NAME frameyap.cli COMMAND "${CMAKE_COMMAND}" "-DAPP=$<TARGET_FILE:frameyap>"
|
||||
"-DEXPECTED_VERSION=${PROJECT_VERSION}" -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/cli.cmake")
|
||||
add_test(NAME frameyap.install_preflight COMMAND sh "${CMAKE_CURRENT_SOURCE_DIR}/tests/install-preflight.sh"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/install-preflight.sh")
|
||||
add_executable(frameyap_core_test tests/core_test.cpp)
|
||||
target_link_libraries(frameyap_core_test PRIVATE frameyap_core)
|
||||
add_test(NAME frameyap.core COMMAND frameyap_core_test)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
add_executable(frameyap_lock_test tests/lock_test.cpp)
|
||||
target_include_directories(frameyap_lock_test PRIVATE src)
|
||||
add_test(NAME frameyap.instance_lock COMMAND frameyap_lock_test)
|
||||
endif()
|
||||
add_executable(frameyap_gestures_test tests/gestures_test.cpp)
|
||||
target_include_directories(frameyap_gestures_test PRIVATE src)
|
||||
target_compile_options(frameyap_gestures_test PRIVATE -UNDEBUG)
|
||||
add_test(NAME frameyap.gestures COMMAND frameyap_gestures_test)
|
||||
find_package(Python3 3.10 COMPONENTS Interpreter)
|
||||
if(FRAMEYAP_NATIVE)
|
||||
pkg_check_modules(WAYLAND_SERVER IMPORTED_TARGET wayland-server)
|
||||
if(WAYLAND_SERVER_FOUND)
|
||||
find_package(Threads REQUIRED)
|
||||
add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-server.h"
|
||||
COMMAND "${WAYLAND_SCANNER}" server-header "${PROTOCOL}" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-server.h"
|
||||
DEPENDS "${PROTOCOL}" VERBATIM)
|
||||
add_executable(frameyap_text_input_test tests/text_input_test.cpp src/text_input.cpp
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-server.h")
|
||||
target_include_directories(frameyap_text_input_test PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
target_link_libraries(frameyap_text_input_test PRIVATE frameyap_core PkgConfig::WAYLAND PkgConfig::WAYLAND_SERVER Threads::Threads)
|
||||
add_test(NAME frameyap.text_input_protocol COMMAND frameyap_text_input_test)
|
||||
set_tests_properties(frameyap.text_input_protocol PROPERTIES TIMEOUT 15)
|
||||
endif()
|
||||
endif()
|
||||
if(Python3_FOUND)
|
||||
add_test(NAME frameyap.python_worker COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_worker.py)
|
||||
add_test(NAME frameyap.installer COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_installer.py)
|
||||
set_tests_properties(frameyap.python_worker frameyap.installer PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 60)
|
||||
if(TARGET frameyap_worker)
|
||||
add_executable(frameyap_worker_test tests/worker_test.cpp)
|
||||
target_link_libraries(frameyap_worker_test PRIVATE frameyap_worker)
|
||||
target_compile_options(frameyap_worker_test PRIVATE -UNDEBUG)
|
||||
add_test(NAME frameyap.worker_ipc COMMAND frameyap_worker_test "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_worker.py")
|
||||
set_tests_properties(frameyap.worker_ipc PROPERTIES TIMEOUT 30)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"default_bindings": [
|
||||
{ "controller_type": "frame_controller", "binding_url": "bindings_frame_controller.json" },
|
||||
{ "controller_type": "knuckles", "binding_url": "bindings_knuckles.json" }
|
||||
],
|
||||
"action_sets": [
|
||||
{ "name": "/actions/frameyap", "usage": "single" }
|
||||
],
|
||||
"actions": [
|
||||
{ "name": "/actions/frameyap/in/left_grip", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/right_grip", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/ptt", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/cancel", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/insert", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/enter", "type": "boolean" }
|
||||
],
|
||||
"localization": [{
|
||||
"language_tag": "en_US",
|
||||
"/actions/frameyap": "FrameYap controls",
|
||||
"/actions/frameyap/in/left_grip": "Double tap left grip: Enter (enabled only)",
|
||||
"/actions/frameyap/in/right_grip": "Tap right grip briefly, then squeeze and hold to record; release to finish",
|
||||
"/actions/frameyap/in/ptt": "Hold to record",
|
||||
"/actions/frameyap/in/cancel": "Cancel recording or discard",
|
||||
"/actions/frameyap/in/insert": "Insert reviewed text",
|
||||
"/actions/frameyap/in/enter": "Explicit Enter"
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"source": "builtin",
|
||||
"applications": [
|
||||
{
|
||||
"app_key": "local.frameyap.overlay",
|
||||
"launch_type": "binary",
|
||||
"binary_path_linux": "@ABSOLUTE_INSTALLED_FRAMEYAP_EXECUTABLE@",
|
||||
"binary_path_linux_arm": "@ABSOLUTE_INSTALLED_FRAMEYAP_EXECUTABLE@",
|
||||
"action_manifest_path": "@ABSOLUTE_INSTALLED_ACTIONS_JSON@",
|
||||
"is_dashboard_overlay": true,
|
||||
"strings": {
|
||||
"en_us": {
|
||||
"name": "FrameYap",
|
||||
"description": "Local voice typing overlay"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"description": "Right grip: tap then hold to dictate. Left grip: double tap Enter. Remap other actions in SteamVR.",
|
||||
"name": "FrameYap Steam Frame grips",
|
||||
"bindings": {
|
||||
"/actions/frameyap": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/left_grip" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/right_grip" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"controller_type": "knuckles",
|
||||
"description": "Example Index controller grip taps; other actions require user rebinding",
|
||||
"name": "FrameYap sample grip bindings",
|
||||
"bindings": {
|
||||
"/actions/frameyap": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/left_grip" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/right_grip" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="gamescope_input_method">
|
||||
|
||||
<copyright>
|
||||
Copyright © 2008-2011 Kristian Høgsberg
|
||||
Copyright © 2010-2011 Intel Corporation
|
||||
Copyright © 2012-2013 Collabora, Ltd.
|
||||
Copyright © 2012, 2013 Intel Corporation
|
||||
Copyright © 2015, 2016 Jan Arne Petersen
|
||||
Copyright © 2017, 2018 Red Hat, Inc.
|
||||
Copyright © 2018 Purism SPC
|
||||
Copyright © 2021 Valve Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="Protocol for creating input methods">
|
||||
This protocol allows applications to act as input methods for compositors.
|
||||
|
||||
An input method context is used to manage the state of the input method.
|
||||
|
||||
Text strings are UTF-8 encoded, their indices and lengths are in bytes.
|
||||
|
||||
This iteration of the protocol is a subset of this work-in-progress proposal:
|
||||
https://gitlab.freedesktop.org/wayland/wayland-protocols/-/merge_requests/112
|
||||
|
||||
This is a private Gamescope protocol. Regular Wayland clients must not use
|
||||
it.
|
||||
</description>
|
||||
|
||||
<interface name="gamescope_input_method_manager" version="3">
|
||||
<description summary="input method manager">
|
||||
The input method manager allows the client to become the input method on
|
||||
a chosen seat.
|
||||
|
||||
No more than one input method must be associated with any seat at any
|
||||
given time.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the input method manager">
|
||||
Destroys the gamescope_input_method_manager object.
|
||||
|
||||
The gamescope_input_method objects originating from it remain valid.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="create_input_method">
|
||||
<description summary="create an input method object">
|
||||
Create a new gamescope_input_method object associated with a given
|
||||
seat.
|
||||
</description>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
<arg name="input_method" type="new_id" interface="gamescope_input_method"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="gamescope_input_method" version="3">
|
||||
<description summary="input method">
|
||||
An input method object allows for clients to compose text.
|
||||
|
||||
The objects connects the client to a text input in an application, and
|
||||
lets the client to serve as an input method for a seat.
|
||||
|
||||
There must be no more than one input method object per seat.
|
||||
</description>
|
||||
|
||||
<event name="unavailable">
|
||||
<description summary="input method unavailable">
|
||||
The input method ceased to be available.
|
||||
|
||||
The compositor must issue this event as the only event on the object if
|
||||
there was another input_method object associated with the same seat at
|
||||
the time of its creation.
|
||||
|
||||
The compositor must issue this request when the object is no longer
|
||||
useable, e.g. due to seat removal.
|
||||
|
||||
The input method context becomes inert and should be destroyed. Any
|
||||
further requests and events except for the destroy request must be
|
||||
ignored.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="done">
|
||||
<description summary="compositor state change notification">
|
||||
Notify the client that the compositor state has changed.
|
||||
|
||||
The serial is used to synchronize compositor state changes and client
|
||||
state changes. The serial allows the compositor to ignore requests
|
||||
meant for a previous text input.
|
||||
|
||||
In the future, will be used to atomically apply compositor state
|
||||
changes sent to the client (but currently there's no such state).
|
||||
</description>
|
||||
<arg name="serial" type="uint"/>
|
||||
</event>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the text input">
|
||||
Destroys the gamescope_text_input object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="commit">
|
||||
<description summary="apply state">
|
||||
Apply state changes from set_string and set_action requests.
|
||||
|
||||
The state relating to these events is double-buffered, and each one
|
||||
modifies the pending state. This request replaces the current state
|
||||
with the pending state.
|
||||
|
||||
The client must set the serial to the last received serial in the done
|
||||
event.
|
||||
</description>
|
||||
<arg name="serial" type="uint" summary="last received serial"/>
|
||||
</request>
|
||||
|
||||
<request name="set_string">
|
||||
<description summary="set string to be committed">
|
||||
Send the string text for insertion to the application.
|
||||
|
||||
Inserts a string at current cursor position (see commit event
|
||||
sequence). The string to commit could be either just a single character
|
||||
after a key press or the result of some composing.
|
||||
|
||||
Values set with this event are double-buffered. They must be applied
|
||||
and reset to initial on the next gamescope_text_input.commit request.
|
||||
|
||||
The initial value of text is an empty string.
|
||||
</description>
|
||||
<arg name="text" type="string" summary="text to insert"/>
|
||||
</request>
|
||||
|
||||
<enum name="action">
|
||||
<description summary="action">
|
||||
A possible action to perform on a text input.
|
||||
|
||||
The backspace and delete actions should be handled in a similar manner
|
||||
to backpace and delete keys being pressed on a keyboard.
|
||||
</description>
|
||||
<entry name="none" value="0" summary="no action"/>
|
||||
<entry name="submit" value="1" summary="submit"/>
|
||||
<entry name="delete_left" value="2" summary="delete one unit before the cursor"/>
|
||||
<entry name="delete_right" value="3" summary="delete one unit after the cursor"/>
|
||||
<entry name="move_left" value="4" summary="move the cursor to the left by one unit"/>
|
||||
<entry name="move_right" value="5" summary="move the cursor to the right by one unit"/>
|
||||
<entry name="move_up" value="6" since="2" summary="move the cursor up by one unit"/>
|
||||
<entry name="move_down" value="7" since="2" summary="move the cursor down by one unit"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_action">
|
||||
<description summary="perform an action">
|
||||
Set the action to be performed on commit.
|
||||
|
||||
Values set with this event are double-buffered. They must be applied
|
||||
and reset to initial on the next gamescope_text_input.commit request.
|
||||
</description>
|
||||
<arg name="action" type="uint" enum="action" summary="action performed"/>
|
||||
</request>
|
||||
|
||||
<!-- Collection of pointer/button related things for Steam Input. -->
|
||||
|
||||
<enum name="button_state" since="3">
|
||||
<description summary="physical button state">
|
||||
Describes the physical state of a button that produced the button
|
||||
event.
|
||||
</description>
|
||||
<entry name="released" value="0" summary="the button is not pressed"/>
|
||||
<entry name="pressed" value="1" summary="the button is pressed"/>
|
||||
</enum>
|
||||
|
||||
<request name="pointer_motion" since="3">
|
||||
<description summary="move the cursor!"/>
|
||||
<arg name="dx" type="fixed" summary="delta x"/>
|
||||
<arg name="dy" type="fixed" summary="delta y"/>
|
||||
</request>
|
||||
|
||||
<request name="pointer_warp" since="3">
|
||||
<description summary="warp the cursor!"/>
|
||||
<arg name="x" type="fixed" summary="x pos"/>
|
||||
<arg name="y" type="fixed" summary="y pos"/>
|
||||
</request>
|
||||
|
||||
<request name="pointer_wheel" since="3">
|
||||
<description summary="scroll the cursor! Uses the 120 scaling mechanism."/>
|
||||
<arg name="x" type="int" summary="x * 120 -> 120 = 1.0"/>
|
||||
<arg name="y" type="int" summary="y * 120 -> 120 = 1.0"/>
|
||||
</request>
|
||||
|
||||
<request name="pointer_button" since="3">
|
||||
<description summary="click the cursor!"/>
|
||||
<arg name="button" type="uint" summary="Linux button code, eg. BTN_LEFT"/>
|
||||
<arg name="state" type="uint" enum="button_state" summary="wl_pointer_button_state" />
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -0,0 +1,72 @@
|
||||
#include "audio.hpp"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace frameyap {
|
||||
namespace { void check(bool result) { if (!result) throw std::runtime_error(std::string("Microphone: ") + SDL_GetError()); } }
|
||||
Audio::~Audio() { cancel(); }
|
||||
void Audio::start() {
|
||||
if (stream_) throw std::runtime_error("Already recording");
|
||||
check(SDL_InitSubSystem(SDL_INIT_AUDIO)); initialized_ = true;
|
||||
SDL_AudioSpec spec{SDL_AUDIO_F32, 1, 16000};
|
||||
stream_ = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, &spec, nullptr, nullptr);
|
||||
if (!stream_) { cancel(); check(false); }
|
||||
pcm_.clear(); pcm_.reserve(320000);
|
||||
started_ = last_data_ = std::chrono::steady_clock::now();
|
||||
try { check(SDL_ResumeAudioStreamDevice(stream_)); }
|
||||
catch (...) { cancel(); throw; }
|
||||
}
|
||||
void Audio::drain() {
|
||||
std::array<float, 4096> chunk{};
|
||||
while (pcm_.size() < 320000) {
|
||||
int available = SDL_GetAudioStreamAvailable(stream_);
|
||||
check(available >= 0);
|
||||
if (available < int(sizeof(float))) break;
|
||||
const int count = static_cast<int>(std::min({chunk.size(), std::size_t(available) / sizeof(float), 320000 - pcm_.size()}));
|
||||
int bytes = SDL_GetAudioStreamData(stream_, chunk.data(), count * sizeof(float));
|
||||
check(bytes >= 0);
|
||||
if (bytes == 0) break;
|
||||
if (bytes % sizeof(float)) throw std::runtime_error("Misaligned microphone samples");
|
||||
for (int i = 0; i < bytes / int(sizeof(float)); ++i)
|
||||
if (!std::isfinite(chunk[i])) throw std::runtime_error("Invalid microphone samples");
|
||||
pcm_.insert(pcm_.end(), chunk.begin(), chunk.begin() + bytes / sizeof(float));
|
||||
last_data_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
bool Audio::poll() {
|
||||
if (!stream_) return false;
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED && event.adevice.recording)
|
||||
throw std::runtime_error("Recording device removed; clip discarded");
|
||||
}
|
||||
drain();
|
||||
if (std::chrono::steady_clock::now() - last_data_ > std::chrono::seconds(2))
|
||||
throw std::runtime_error("Microphone stalled; clip discarded");
|
||||
return pcm_.size() == 320000 || seconds() >= 20;
|
||||
}
|
||||
std::vector<float> Audio::finish() {
|
||||
if (!stream_) return {};
|
||||
check(SDL_PauseAudioStreamDevice(stream_));
|
||||
check(SDL_FlushAudioStream(stream_));
|
||||
drain();
|
||||
auto result = std::move(pcm_);
|
||||
cancel();
|
||||
return result;
|
||||
}
|
||||
void Audio::cancel() {
|
||||
if (stream_) SDL_DestroyAudioStream(stream_);
|
||||
stream_ = nullptr;
|
||||
// Best effort clearing of the owned buffer; no persistent recording/logging.
|
||||
std::fill(pcm_.begin(), pcm_.end(), 0.0f); pcm_.clear();
|
||||
if (initialized_) SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||
initialized_ = false;
|
||||
}
|
||||
int Audio::seconds() const {
|
||||
return stream_ ? int(std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now() - started_).count()) : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
struct SDL_AudioStream;
|
||||
namespace frameyap {
|
||||
class Audio {
|
||||
public:
|
||||
Audio() = default;
|
||||
~Audio();
|
||||
void start();
|
||||
bool poll(); // true at 20s limit; throws if capture was lost/stalled
|
||||
std::vector<float> finish();
|
||||
void cancel();
|
||||
bool recording() const { return stream_ != nullptr; }
|
||||
int seconds() const;
|
||||
private:
|
||||
void drain();
|
||||
SDL_AudioStream* stream_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
std::vector<float> pcm_;
|
||||
std::chrono::steady_clock::time_point started_, last_data_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace frameyap {
|
||||
// Feed monotonic samples. Inactive, tracking/focus loss and reconnect require an
|
||||
// observed neutral sample before any new press can be interpreted.
|
||||
class DoubleTap {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
bool update(bool active, bool down, bool enabled, Clock::time_point now) {
|
||||
if (!active || !enabled) { reset(); return false; }
|
||||
if (!armed_) {
|
||||
if (!down) { armed_ = true; previous_ = false; }
|
||||
return false;
|
||||
}
|
||||
if (down == previous_) return false;
|
||||
previous_ = down;
|
||||
if (down) {
|
||||
pressed_ = now;
|
||||
if (waiting_ && now >= released_ && now - released_ <= std::chrono::milliseconds(350)) {
|
||||
waiting_ = false;
|
||||
// The second tap must itself be short; report on its release.
|
||||
second_ = true;
|
||||
} else { waiting_ = false; second_ = false; }
|
||||
return false;
|
||||
}
|
||||
if (now < pressed_ || now - pressed_ > std::chrono::milliseconds(250)) {
|
||||
waiting_ = second_ = false;
|
||||
return false;
|
||||
}
|
||||
if (second_) { second_ = false; waiting_ = false; return true; }
|
||||
waiting_ = true;
|
||||
released_ = now;
|
||||
return false;
|
||||
}
|
||||
void reset() { armed_ = previous_ = waiting_ = second_ = false; }
|
||||
private:
|
||||
bool armed_ = false, previous_ = false, waiting_ = false, second_ = false;
|
||||
Clock::time_point pressed_{}, released_{};
|
||||
};
|
||||
|
||||
// First short right-grip tap (<=250ms), then second down within 350ms
|
||||
// begins capture immediately; second release ends it, regardless of its length.
|
||||
// Any loss during capture cancels, and reconnect requires an observed neutral.
|
||||
class GripRecord {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
enum class Change { None, Begin, End, Cancel };
|
||||
Change update(bool active, bool down, Clock::time_point now) {
|
||||
if (!active) return reset();
|
||||
if (!armed_) {
|
||||
if (!down) { armed_ = true; previous_ = false; }
|
||||
return Change::None;
|
||||
}
|
||||
if (down == previous_) return Change::None;
|
||||
previous_ = down;
|
||||
if (down) {
|
||||
if (waiting_ && now >= released_ && now - released_ <= std::chrono::milliseconds(350)) {
|
||||
waiting_ = false;
|
||||
holding_ = true;
|
||||
return Change::Begin;
|
||||
}
|
||||
waiting_ = false;
|
||||
pressed_ = now;
|
||||
return Change::None;
|
||||
}
|
||||
if (holding_) { holding_ = false; return Change::End; }
|
||||
if (now >= pressed_ && now - pressed_ <= std::chrono::milliseconds(250)) {
|
||||
waiting_ = true;
|
||||
released_ = now;
|
||||
} else waiting_ = false;
|
||||
return Change::None;
|
||||
}
|
||||
Change reset() {
|
||||
bool was_holding = holding_;
|
||||
armed_ = previous_ = waiting_ = holding_ = false;
|
||||
return was_holding ? Change::Cancel : Change::None;
|
||||
}
|
||||
private:
|
||||
bool armed_ = false, previous_ = false, waiting_ = false, holding_ = false;
|
||||
Clock::time_point pressed_{}, released_{};
|
||||
};
|
||||
|
||||
// Edge detector for hold-to-talk: first observed active state never starts recording.
|
||||
class NeutralEdge {
|
||||
public:
|
||||
enum class Change { None, Down, Up };
|
||||
Change update(bool active, bool down) {
|
||||
if (!active) { reset(); return Change::None; }
|
||||
if (!armed_) {
|
||||
if (!down) armed_ = true;
|
||||
return Change::None;
|
||||
}
|
||||
if (down == previous_) return Change::None;
|
||||
previous_ = down;
|
||||
return down ? Change::Down : Change::Up;
|
||||
}
|
||||
bool reset() {
|
||||
const bool was_down = armed_ && previous_;
|
||||
armed_ = previous_ = false;
|
||||
return was_down;
|
||||
}
|
||||
private:
|
||||
bool armed_ = false, previous_ = false;
|
||||
};
|
||||
} // namespace frameyap
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fcntl.h>
|
||||
#include <stdexcept>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace frameyap {
|
||||
// Shared contract with install.sh. Held for the entire runtime/check/registration
|
||||
// operation, never acquired by default/help/version or hardware-free tests.
|
||||
class InstanceLock {
|
||||
public:
|
||||
InstanceLock() {
|
||||
const char* installed = std::getenv("FRAMEYAP_INSTALL_ROOT");
|
||||
const char* data = std::getenv("XDG_DATA_HOME");
|
||||
const char* home = std::getenv("HOME");
|
||||
if ((!installed || !*installed) && (!data || !*data) && (!home || !*home))
|
||||
throw std::runtime_error("HOME or XDG_DATA_HOME required");
|
||||
auto root = installed && *installed ? std::filesystem::path(installed) :
|
||||
std::filesystem::path(data && *data ? data : std::string(home) + "/.local/share") / "frameyap";
|
||||
if (!root.is_absolute() || std::filesystem::is_symlink(root))
|
||||
throw std::runtime_error("Data directory must be absolute and not a symlink");
|
||||
std::filesystem::create_directories(root);
|
||||
fd_ = open((root / ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
struct stat st{};
|
||||
if (fd_ < 0 || fstat(fd_, &st) || !S_ISREG(st.st_mode) || st.st_uid != getuid() || flock(fd_, LOCK_EX | LOCK_NB)) {
|
||||
if (fd_ >= 0) close(fd_);
|
||||
fd_ = -1;
|
||||
throw std::runtime_error("FrameYap already running/installing, or unsafe install lock");
|
||||
}
|
||||
}
|
||||
~InstanceLock() { if (fd_ >= 0) close(fd_); }
|
||||
InstanceLock(const InstanceLock&) = delete;
|
||||
InstanceLock& operator=(const InstanceLock&) = delete;
|
||||
private:
|
||||
int fd_ = -1;
|
||||
};
|
||||
}
|
||||
+97
-13
@@ -1,24 +1,108 @@
|
||||
#include <iostream>
|
||||
#include <string_view>
|
||||
|
||||
#include <string>
|
||||
#include <cstdlib>
|
||||
#ifdef FRAMEYAP_NATIVE
|
||||
#include "runtime.hpp"
|
||||
#include "overlay.hpp"
|
||||
#include "text_input.hpp"
|
||||
#include "instance_lock.hpp"
|
||||
#include <openvr.h>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#endif
|
||||
namespace {
|
||||
void help() {
|
||||
std::cout << "Frame Dictation — standalone Steam Frame voice typing\n"
|
||||
"Scaffold only: overlay, microphone, ASR and input are not implemented.\n\n"
|
||||
"Usage: frame-dictation [--help | --version]\n"
|
||||
"No device access or background processes are started.\n";
|
||||
std::cout << "FrameYap — standalone Steam Frame voice typing POC\n"
|
||||
"Usage: frameyap [--help | --version]\n"
|
||||
" --run --assets DIR --font FILE --worker FILE --model DIR\n"
|
||||
" [--python FILE] [--threads 2|4] [--socket gamescope-0] [--head]\n"
|
||||
" --register MANIFEST [--autostart] | --unregister MANIFEST\n"
|
||||
" --check-input [--socket gamescope-0] (discovery only, no typing)\n"
|
||||
" --check-overlay --assets DIR --font FILE [--head] (5s, no mic/input)\n"
|
||||
" --check-controls --assets DIR --font FILE [--head] (30s, gestures only)\n\n"
|
||||
"Right grip: double-tap, hold second squeeze to speak, release to review.\n"
|
||||
"Left grip: double-tap for explicit Enter. Bindings are remappable.\n"
|
||||
"Review first: Insert approves current focus; never automatic Enter.\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";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc == 1 || (argc == 2 && std::string_view(argv[1]) == "--help")) {
|
||||
help();
|
||||
return 0;
|
||||
}
|
||||
if (argc == 1 || (argc == 2 && std::string_view(argv[1]) == "--help")) { help(); return 0; }
|
||||
if (argc == 2 && std::string_view(argv[1]) == "--version") {
|
||||
std::cout << "frame-dictation " << FRAME_DICTATION_VERSION << " (scaffold)\n";
|
||||
return 0;
|
||||
std::cout << "frameyap " << FRAMEYAP_VERSION << "\n"; return 0;
|
||||
}
|
||||
std::cerr << "Unsupported arguments. Use --help; runtime features are not implemented.\n";
|
||||
#ifdef FRAMEYAP_NATIVE
|
||||
try {
|
||||
const std::string mode = argv[1];
|
||||
if (mode == "--register" || mode == "--unregister") {
|
||||
if (argc == 3 || (mode == "--register" && argc == 4 && std::string_view(argv[3]) == "--autostart")) {
|
||||
frameyap::InstanceLock lock;
|
||||
return frameyap::registration(argv[2], mode == "--unregister", argc == 4);
|
||||
}
|
||||
} else if (mode == "--run" || mode == "--check-input" || mode == "--check-overlay" || mode == "--check-controls") {
|
||||
frameyap::Options options;
|
||||
const char* socket = std::getenv("GAMESCOPE_WAYLAND_DISPLAY");
|
||||
options.socket = socket && *socket ? socket : "gamescope-0";
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
std::string_view arg = argv[i];
|
||||
if (arg == "--head" && mode != "--check-input") { options.hand = false; continue; }
|
||||
if (i + 1 == argc) throw std::runtime_error("Missing option value");
|
||||
std::string value = argv[++i];
|
||||
if (arg == "--socket" && mode != "--check-overlay") options.socket = value;
|
||||
else if (arg == "--assets" && mode != "--check-input") options.assets = value;
|
||||
else if (arg == "--font" && mode != "--check-input") options.font = value;
|
||||
else if (arg == "--worker" && mode == "--run") options.worker = value;
|
||||
else if (arg == "--python" && mode == "--run") options.python = value;
|
||||
else if (arg == "--model" && mode == "--run") options.model = value;
|
||||
else if (arg == "--threads" && mode == "--run" && (value == "2" || value == "4")) options.threads = value == "2" ? 2 : 4;
|
||||
else throw std::runtime_error("Unsupported option/value");
|
||||
}
|
||||
if (mode == "--check-input") {
|
||||
frameyap::InstanceLock lock;
|
||||
frameyap::TextInput input(options.socket);
|
||||
std::cout << "Gamescope IME v2 ready; no input delivered.\n"; return 0;
|
||||
}
|
||||
if (options.assets.empty() || options.font.empty()) throw std::runtime_error("--assets and --font required");
|
||||
if (mode == "--check-overlay" || mode == "--check-controls") {
|
||||
frameyap::InstanceLock lock;
|
||||
frameyap::Overlay overlay(options.assets, options.font, options.hand);
|
||||
overlay.draw({"FrameYap five-second visual check", "No audio captured. No text or Enter delivered.", "Controls inactive during this check.", false, false});
|
||||
for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i) {
|
||||
if (vr::VRSystem()->GetTrackedDeviceClass(i) != vr::TrackedDeviceClass_Controller) continue;
|
||||
char type[256]{}, profile[512]{};
|
||||
vr::VRSystem()->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof(type));
|
||||
vr::VRSystem()->GetStringTrackedDeviceProperty(i, vr::Prop_InputProfilePath_String, profile, sizeof(profile));
|
||||
std::cout << "Controller type=" << type << " profile=" << profile << '\n';
|
||||
}
|
||||
if (mode == "--check-controls") {
|
||||
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
std::string status;
|
||||
bool held = false;
|
||||
while (std::chrono::steady_clock::now() < end) {
|
||||
overlay.draw({"Controls check ONLY - no mic or typing", "Tap then hold right grip; double-tap left grip.",
|
||||
held ? "HOLD gesture recognized. Release to finish." : "Try grips or click buttons. Auto-closes after 30 seconds.", true, held});
|
||||
for (auto action : overlay.poll()) {
|
||||
if (action == frameyap::UiAction::Quit) return 0;
|
||||
if (action == frameyap::UiAction::BeginRecord) held = true;
|
||||
if (action == frameyap::UiAction::EndRecord || action == frameyap::UiAction::Cancel) held = false;
|
||||
std::cout << "Control action=" << static_cast<int>(action) << " (diagnostic only)" << std::endl;
|
||||
}
|
||||
auto next = overlay.controls_status();
|
||||
if (next != status) { status = next; std::cout << status << std::endl; }
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
} else std::this_thread::sleep_for(std::chrono::seconds(5));
|
||||
std::cout << "Overlay APIs accepted panel; physical visibility requires human confirmation.\n"; return 0;
|
||||
}
|
||||
if (options.worker.empty() || options.model.empty()) throw std::runtime_error("--worker and --model required");
|
||||
return frameyap::run(options);
|
||||
}
|
||||
} catch (const std::exception& e) { std::cerr << "FrameYap: " << e.what() << '\n'; return 1; }
|
||||
#endif
|
||||
std::cerr << "Unsupported arguments or runtime disabled in this build. Use --help.\n";
|
||||
return 2;
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
#include "overlay.hpp"
|
||||
#include "gestures.hpp"
|
||||
|
||||
#include <openvr.h>
|
||||
#include <ft2build.h>
|
||||
#include FT_FREETYPE_H
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace frameyap {
|
||||
namespace {
|
||||
// The ARM64 SDK defaults to /data/work on Frame. Respect explicit overrides,
|
||||
// otherwise use the user's standard OpenVR registry when it exists.
|
||||
void configure_registry() {
|
||||
if (std::getenv("VR_PATHREG_OVERRIDE")) return;
|
||||
const char* config = std::getenv("XDG_CONFIG_HOME");
|
||||
const char* home = std::getenv("HOME");
|
||||
if ((!config || !*config) && !home) return;
|
||||
auto root = std::filesystem::path(config && *config ? config : std::string(home) + "/.config");
|
||||
auto registry = root / "openvr/openvrpaths.vrpath";
|
||||
if (root.is_absolute() && std::filesystem::is_regular_file(registry))
|
||||
::setenv("VR_PATHREG_OVERRIDE", registry.c_str(), 0);
|
||||
}
|
||||
constexpr int W = 900, H = 500;
|
||||
struct Rect { int x, y, w, h; bool contains(int px, int py) const {
|
||||
return px >= x && px < x + w && py >= y && py < y + h;
|
||||
} };
|
||||
struct Button { Rect bounds; const char* label; UiAction action; };
|
||||
constexpr std::array<Button, 6> buttons{{
|
||||
{{24, 422, 130, 58}, "Prev", UiAction::Toggle}, // handled as page navigation, not a toggle
|
||||
{{168, 422, 130, 58}, "Record", UiAction::Record},
|
||||
{{312, 422, 130, 58}, "Cancel", UiAction::Cancel},
|
||||
{{456, 422, 130, 58}, "Insert", UiAction::Insert},
|
||||
{{600, 422, 130, 58}, "Enter", UiAction::Enter},
|
||||
{{744, 422, 130, 58}, "Quit", UiAction::Quit},
|
||||
}};
|
||||
void overlay_check(vr::EVROverlayError err, vr::IVROverlay* api, const char* op) {
|
||||
if (err != vr::VROverlayError_None)
|
||||
throw std::runtime_error(std::string(op) + ": " + api->GetOverlayErrorNameFromEnum(err));
|
||||
}
|
||||
std::filesystem::path absolute_file(const std::filesystem::path& p) {
|
||||
auto result = std::filesystem::absolute(p);
|
||||
if (!std::filesystem::is_regular_file(result)) throw std::runtime_error("Missing file: " + result.string());
|
||||
return result;
|
||||
}
|
||||
// A strict, bounded UTF-8 decoder: malformed input is shown as replacement glyphs.
|
||||
uint32_t next_codepoint(std::string_view s, size_t& i) {
|
||||
const unsigned char a = static_cast<unsigned char>(s[i++]);
|
||||
if (a < 0x80) return a;
|
||||
int count = a >= 0xc2 && a <= 0xdf ? 1 : a >= 0xe0 && a <= 0xef ? 2 : a >= 0xf0 && a <= 0xf4 ? 3 : 0;
|
||||
if (!count || i + count > s.size()) return 0xfffd;
|
||||
uint32_t value = a & (count == 1 ? 0x1f : count == 2 ? 0x0f : 0x07);
|
||||
for (int k = 0; k < count; ++k) {
|
||||
unsigned char b = static_cast<unsigned char>(s[i + k]);
|
||||
if ((b & 0xc0) != 0x80) return 0xfffd;
|
||||
value = (value << 6) | (b & 0x3f);
|
||||
}
|
||||
if ((count == 1 && value < 0x80) || (count == 2 && value < 0x800) ||
|
||||
(count == 3 && value < 0x10000) || (value >= 0xd800 && value <= 0xdfff) || value > 0x10ffff)
|
||||
return 0xfffd;
|
||||
i += count;
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct Overlay::Impl {
|
||||
vr::IVRSystem* system = nullptr;
|
||||
vr::IVROverlay* overlay = nullptr;
|
||||
vr::IVRInput* input = nullptr;
|
||||
vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid;
|
||||
vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle;
|
||||
std::array<vr::VRActionHandle_t, 6> actions{};
|
||||
FT_Library library = nullptr;
|
||||
FT_Face face = nullptr;
|
||||
std::vector<unsigned char> pixels = std::vector<unsigned char>(W * H * 4);
|
||||
Panel panel;
|
||||
bool hand = false;
|
||||
vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid;
|
||||
DoubleTap left;
|
||||
GripRecord right;
|
||||
std::array<NeutralEdge, 4> edges{};
|
||||
std::array<int, 2> pressed_button{{-1, -1}};
|
||||
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses{};
|
||||
std::vector<std::string> pages;
|
||||
size_t page = 0;
|
||||
bool drawn = false;
|
||||
bool grip_capture = false, ptt_capture = false;
|
||||
bool focus = true;
|
||||
static constexpr Rect next_page{754, 292, 112, 30};
|
||||
|
||||
Impl(const std::string& assets, const std::string& font, bool attach) : hand(attach) {
|
||||
const auto action_path = absolute_file(std::filesystem::path(assets) / "actions.json");
|
||||
absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json");
|
||||
const auto font_path = absolute_file(font);
|
||||
if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed");
|
||||
try {
|
||||
if (FT_New_Face(library, font_path.c_str(), 0, &face) || FT_Set_Pixel_Sizes(face, 0, 31))
|
||||
throw std::runtime_error("Could not load specified font");
|
||||
configure_registry();
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
system = vr::VR_Init(&err, vr::VRApplication_Overlay);
|
||||
if (err != vr::VRInitError_None || !system)
|
||||
throw std::runtime_error(std::string("OpenVR overlay init: ") + vr::VR_GetVRInitErrorAsEnglishDescription(err));
|
||||
overlay = vr::VROverlay();
|
||||
input = vr::VRInput();
|
||||
if (!overlay || !input) throw std::runtime_error("OpenVR overlay/input interface unavailable");
|
||||
// The manifest launches a shell launcher, then execs this binary.
|
||||
// Associate manually launched instances with our registered app key too.
|
||||
auto* applications = vr::VRApplications();
|
||||
if (applications && applications->IsApplicationInstalled("local.frameyap.overlay") &&
|
||||
applications->IdentifyApplication(static_cast<uint32_t>(::getpid()), "local.frameyap.overlay") != vr::VRApplicationError_None)
|
||||
throw std::runtime_error("Could not identify the registered FrameYap application");
|
||||
if (input->SetActionManifestPath(action_path.c_str()) != vr::VRInputError_None)
|
||||
throw std::runtime_error("Could not set OpenVR action manifest path");
|
||||
if (input->GetActionSetHandle("/actions/frameyap", &action_set) != vr::VRInputError_None)
|
||||
throw std::runtime_error("Could not find FrameYap action set");
|
||||
constexpr std::array<const char*, 6> names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}};
|
||||
for (size_t i = 0; i < names.size(); ++i)
|
||||
if (input->GetActionHandle((std::string("/actions/frameyap/in/") + names[i]).c_str(), &actions[i]) != vr::VRInputError_None)
|
||||
throw std::runtime_error(std::string("Could not find action ") + names[i]);
|
||||
overlay_check(overlay->CreateOverlay("local.frameyap.overlay.panel", "FrameYap", &handle), overlay, "CreateOverlay");
|
||||
overlay_check(overlay->SetOverlayWidthInMeters(handle, 0.85f), overlay, "SetOverlayWidthInMeters");
|
||||
overlay_check(overlay->SetOverlayInputMethod(handle, vr::VROverlayInputMethod_Mouse), overlay, "SetOverlayInputMethod");
|
||||
// Let dashboard lasers reach our controls without forcing global
|
||||
// laser-mouse mode over a running scene.
|
||||
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");
|
||||
place();
|
||||
draw(Panel{"Disabled", "", "Record to start local worker", false, false});
|
||||
} catch (...) { cleanup(); throw; }
|
||||
}
|
||||
~Impl() { cleanup(); }
|
||||
void cleanup() {
|
||||
if (overlay && handle != vr::k_ulOverlayHandleInvalid) {
|
||||
overlay->HideOverlay(handle);
|
||||
overlay->DestroyOverlay(handle);
|
||||
handle = vr::k_ulOverlayHandleInvalid;
|
||||
}
|
||||
if (system) { vr::VR_Shutdown(); system = nullptr; overlay = nullptr; input = nullptr; }
|
||||
if (face) { FT_Done_Face(face); face = nullptr; }
|
||||
if (library) { FT_Done_FreeType(library); library = nullptr; }
|
||||
}
|
||||
void place() {
|
||||
vr::TrackedDeviceIndex_t target = vr::k_unTrackedDeviceIndex_Hmd;
|
||||
if (hand) {
|
||||
const auto left_index = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
|
||||
if (left_index != vr::k_unTrackedDeviceIndexInvalid && left_index < poses.size() &&
|
||||
poses[left_index].bPoseIsValid && system->IsTrackedDeviceConnected(left_index))
|
||||
target = left_index;
|
||||
}
|
||||
if (anchor == target) return;
|
||||
vr::HmdMatrix34_t transform{{{1.f, 0.f, 0.f, target == vr::k_unTrackedDeviceIndex_Hmd ? 0.f : 0.13f},
|
||||
{0.f, 1.f, 0.f, target == vr::k_unTrackedDeviceIndex_Hmd ? -0.16f : 0.12f},
|
||||
{0.f, 0.f, 1.f, target == vr::k_unTrackedDeviceIndex_Hmd ? -1.05f : -0.18f}}};
|
||||
overlay_check(overlay->SetOverlayTransformTrackedDeviceRelative(handle, target, &transform), overlay, "SetOverlayTransformTrackedDeviceRelative");
|
||||
anchor = target;
|
||||
}
|
||||
void rect(Rect r, std::array<unsigned char, 4> c) {
|
||||
for (int y = std::max(r.y, 0); y < std::min(H, r.y + r.h); ++y)
|
||||
for (int x = std::max(r.x, 0); x < std::min(W, r.x + r.w); ++x) {
|
||||
const size_t p = (size_t(y) * W + x) * 4;
|
||||
std::copy(c.begin(), c.end(), pixels.begin() + p);
|
||||
}
|
||||
}
|
||||
void text(std::string_view s, int x, int baseline, int max_x, int max_y, int max_bytes) {
|
||||
size_t i = 0;
|
||||
int used = 0;
|
||||
while (i < s.size() && used < max_bytes && baseline < max_y) {
|
||||
const size_t before = i;
|
||||
const auto cp = next_codepoint(s, i);
|
||||
used += int(i - before);
|
||||
if (cp == '\n') { x = 28; baseline += 39; continue; }
|
||||
if (cp == '\r' || cp == '\t') continue;
|
||||
if (FT_Load_Char(face, cp, FT_LOAD_RENDER)) continue;
|
||||
const auto glyph = face->glyph;
|
||||
if (x + (glyph->advance.x >> 6) >= max_x) { x = 28; baseline += 39; }
|
||||
if (baseline >= max_y) break;
|
||||
const auto& b = glyph->bitmap;
|
||||
for (unsigned row = 0; row < b.rows; ++row)
|
||||
for (unsigned col = 0; col < b.width; ++col) {
|
||||
int px = x + glyph->bitmap_left + int(col);
|
||||
int py = baseline - glyph->bitmap_top + int(row);
|
||||
if (px < 0 || px >= W || py < 0 || py >= max_y) continue;
|
||||
const unsigned char alpha = b.buffer[int(row) * b.pitch + int(col)];
|
||||
auto* dst = pixels.data() + (size_t(py) * W + px) * 4;
|
||||
for (int c = 0; c < 3; ++c)
|
||||
dst[c] = static_cast<unsigned char>((unsigned(dst[c]) * (255 - alpha) + 240u * alpha) / 255);
|
||||
}
|
||||
x += glyph->advance.x >> 6;
|
||||
}
|
||||
}
|
||||
bool available(UiAction a) const {
|
||||
switch (a) {
|
||||
case UiAction::Toggle: return page > 0;
|
||||
case UiAction::Quit: case UiAction::Record: case UiAction::Cancel: return true;
|
||||
case UiAction::BeginRecord: case UiAction::EndRecord: return true;
|
||||
case UiAction::Enter: return panel.enabled;
|
||||
case UiAction::Insert: return panel.enabled && !panel.transcript.empty();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Split by rendered glyph advances, never by byte count or arbitrary characters.
|
||||
// UTF-8 slices remain intact; all bytes (including invalid UTF-8) stay visible.
|
||||
std::vector<std::string> paginate(std::string_view s, int lines, int right) {
|
||||
std::vector<std::string> out;
|
||||
size_t start = 0, i = 0;
|
||||
int x = 28, line = 0;
|
||||
while (i < s.size()) {
|
||||
size_t before = i;
|
||||
auto cp = next_codepoint(s, i);
|
||||
if (cp == '\n') {
|
||||
if (++line == lines) {
|
||||
out.emplace_back(s.substr(start, i - start));
|
||||
start = i; line = 0;
|
||||
}
|
||||
x = 28;
|
||||
continue;
|
||||
}
|
||||
int advance = 0;
|
||||
if (cp != '\r' && cp != '\t' && !FT_Load_Char(face, cp, FT_LOAD_DEFAULT))
|
||||
advance = face->glyph->advance.x >> 6;
|
||||
if (x + advance >= right && x != 28) {
|
||||
if (++line == lines) {
|
||||
out.emplace_back(s.substr(start, before - start));
|
||||
start = before; line = 0;
|
||||
} else {
|
||||
// Add a line break in the displayed page while keeping source intact.
|
||||
// The renderer's own width wrapping uses the same advance rule.
|
||||
}
|
||||
x = 28;
|
||||
}
|
||||
x += advance;
|
||||
}
|
||||
if (start < s.size() || out.empty()) out.emplace_back(s.substr(start));
|
||||
return out;
|
||||
}
|
||||
void draw(const Panel& p) {
|
||||
if (p.transcript != panel.transcript || pages.empty()) {
|
||||
pages = paginate(p.transcript.empty() ? "Transcript preview" : p.transcript, 3, W - 28);
|
||||
page = 0;
|
||||
drawn = false;
|
||||
}
|
||||
if (drawn && p.status == panel.status && p.transcript == panel.transcript &&
|
||||
p.detail == panel.detail && p.enabled == panel.enabled && p.recording == panel.recording) return;
|
||||
panel = p;
|
||||
rect({0, 0, W, H}, {20, 26, 39, 255});
|
||||
rect({0, 0, W, 12}, {static_cast<unsigned char>(p.recording ? 220 : 55),
|
||||
static_cast<unsigned char>(p.recording ? 82 : 135), 72, 255});
|
||||
text(p.enabled ? "FrameYap | Enabled" : "FrameYap | Disabled", 28, 52, W - 20, 72, 128);
|
||||
const auto status_lines = paginate(p.status, 2, 620);
|
||||
text(status_lines[0], 28, 96, 620, 150, int(status_lines[0].size()));
|
||||
if (status_lines.size() > 1) text("[status truncated]", 635, 140, W - 28, 150, 32);
|
||||
rect({20, 153, W - 40, 171}, {37, 46, 63, 255});
|
||||
text(pages[page], 28, 190, W - 28, 290, int(pages[page].size()));
|
||||
const std::string page_label = "Page " + std::to_string(page + 1) + " / " + std::to_string(pages.size());
|
||||
text(page_label, 383, 315, 720, 324, 64);
|
||||
rect(next_page, page + 1 < pages.size() ? std::array<unsigned char, 4>{56, 94, 129, 255}
|
||||
: std::array<unsigned char, 4>{49, 52, 60, 255});
|
||||
text("Next", 767, 316, 860, 324, 8);
|
||||
const auto detail_lines = paginate(p.detail, 2, 620);
|
||||
text(detail_lines[0], 28, 355, 620, 412, int(detail_lines[0].size()));
|
||||
if (detail_lines.size() > 1)
|
||||
text("[detail truncated]", 635, 405, W - 28, 412, 32);
|
||||
for (const auto& button : buttons) {
|
||||
const bool enabled = available(button.action);
|
||||
rect(button.bounds, enabled ? std::array<unsigned char, 4>{56, 94, 129, 255}
|
||||
: std::array<unsigned char, 4>{49, 52, 60, 255});
|
||||
text(button.label, button.bounds.x + 10, button.bounds.y + 38,
|
||||
button.bounds.x + button.bounds.w - 5, H, 64);
|
||||
}
|
||||
overlay_check(overlay->SetOverlayRaw(handle, pixels.data(), W, H, 4), overlay, "SetOverlayRaw");
|
||||
overlay_check(overlay->ShowOverlay(handle), overlay, "ShowOverlay");
|
||||
drawn = true;
|
||||
}
|
||||
void reset_input(std::vector<UiAction>& result) {
|
||||
left.reset();
|
||||
const bool lost_grip = right.reset() == GripRecord::Change::Cancel || grip_capture;
|
||||
grip_capture = false;
|
||||
const bool lost_ptt = edges[0].reset() || ptt_capture;
|
||||
ptt_capture = false;
|
||||
if (lost_grip || lost_ptt) result.push_back(UiAction::Cancel);
|
||||
for (size_t i = 1; i < edges.size(); ++i) edges[i].reset();
|
||||
pressed_button.fill(-1);
|
||||
}
|
||||
// Bound actions are accepted only with a connected tracked source. A held input
|
||||
// following loss of activity must return to neutral before generating an edge.
|
||||
std::pair<bool, bool> digital(size_t index) {
|
||||
vr::InputDigitalActionData_t data{};
|
||||
if (input->GetDigitalActionData(actions[index], &data, sizeof(data), vr::k_ulInvalidInputValueHandle) != vr::VRInputError_None ||
|
||||
!data.bActive || data.activeOrigin == vr::k_ulInvalidInputValueHandle) return {false, false};
|
||||
vr::InputOriginInfo_t origin{};
|
||||
if (input->GetOriginTrackedDeviceInfo(data.activeOrigin, &origin, sizeof(origin)) != vr::VRInputError_None ||
|
||||
origin.trackedDeviceIndex == vr::k_unTrackedDeviceIndexInvalid ||
|
||||
origin.trackedDeviceIndex >= poses.size() ||
|
||||
!system->IsTrackedDeviceConnected(origin.trackedDeviceIndex) ||
|
||||
!poses[origin.trackedDeviceIndex].bPoseIsValid) return {false, false};
|
||||
if (index < 2) {
|
||||
const auto role = index == 0 ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand;
|
||||
if (system->GetTrackedDeviceIndexForControllerRole(role) != origin.trackedDeviceIndex) return {false, false};
|
||||
}
|
||||
return {true, data.bState};
|
||||
}
|
||||
std::vector<UiAction> poll() {
|
||||
std::vector<UiAction> result;
|
||||
system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size()));
|
||||
place();
|
||||
vr::VREvent_t event{};
|
||||
while (system->PollNextEvent(&event, sizeof(event))) {
|
||||
if (event.eventType == vr::VREvent_Quit) result.push_back(UiAction::Quit);
|
||||
if (event.eventType == vr::VREvent_TrackedDeviceDeactivated ||
|
||||
event.eventType == vr::VREvent_InputFocusCaptured) reset_input(result);
|
||||
}
|
||||
while (overlay->PollNextOverlayEvent(handle, &event, sizeof(event))) {
|
||||
switch (event.eventType) {
|
||||
case vr::VREvent_Quit: case vr::VREvent_OverlayClosed:
|
||||
result.push_back(UiAction::Quit); break;
|
||||
case vr::VREvent_OverlayHidden:
|
||||
focus = false; reset_input(result);
|
||||
if (panel.recording) result.push_back(UiAction::Cancel);
|
||||
break;
|
||||
case vr::VREvent_OverlayShown:
|
||||
focus = true; reset_input(result); break;
|
||||
case vr::VREvent_OverlayGamepadFocusLost:
|
||||
case vr::VREvent_OverlayFocusChanged:
|
||||
// Pointer/gamepad overlay focus is not OS keyboard focus or action
|
||||
// activity. Cancel the old gesture, then require neutral rearm;
|
||||
// do not permanently latch global grip actions off.
|
||||
reset_input(result); break;
|
||||
case vr::VREvent_MouseButtonDown: {
|
||||
const auto& m = event.data.mouse;
|
||||
if (m.button != vr::VRMouseButton_Left || m.cursorIndex >= pressed_button.size()) break;
|
||||
const int x = int(m.x), y = H - int(m.y);
|
||||
pressed_button[m.cursorIndex] = -1;
|
||||
for (size_t i = 0; i < buttons.size(); ++i)
|
||||
if (buttons[i].bounds.contains(x, y) && available(buttons[i].action)) {
|
||||
pressed_button[m.cursorIndex] = int(i); break;
|
||||
}
|
||||
if (next_page.contains(x, y) && page + 1 < pages.size())
|
||||
pressed_button[m.cursorIndex] = int(buttons.size());
|
||||
break;
|
||||
}
|
||||
case vr::VREvent_MouseButtonUp: {
|
||||
const auto& m = event.data.mouse;
|
||||
if (m.button != vr::VRMouseButton_Left || m.cursorIndex >= pressed_button.size()) break;
|
||||
const int index = pressed_button[m.cursorIndex];
|
||||
pressed_button[m.cursorIndex] = -1;
|
||||
if (index == int(buttons.size()) && next_page.contains(int(m.x), H - int(m.y)) &&
|
||||
page + 1 < pages.size()) { ++page; drawn = false; }
|
||||
else if (index == 0 && buttons[0].bounds.contains(int(m.x), H - int(m.y)) && page > 0) {
|
||||
--page; drawn = false;
|
||||
} else if (index > 0 && index < int(buttons.size()) &&
|
||||
buttons[index].bounds.contains(int(m.x), H - int(m.y)) &&
|
||||
available(buttons[index].action)) result.push_back(buttons[index].action);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
vr::VRActiveActionSet_t set{};
|
||||
set.ulActionSet = action_set;
|
||||
set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle;
|
||||
set.nPriority = 0; // normal priority; no experimental scene-input overrides
|
||||
if (input->UpdateActionState(&set, sizeof(set), 1) != vr::VRInputError_None) {
|
||||
reset_input(result); return result;
|
||||
}
|
||||
auto [la, ld] = digital(0);
|
||||
auto [ra, rd] = digital(1);
|
||||
const auto now = DoubleTap::Clock::now();
|
||||
if (left.update(la && focus, ld, panel.enabled, now)) result.push_back(UiAction::Enter);
|
||||
switch (right.update(ra && focus, rd, now)) {
|
||||
case GripRecord::Change::Begin: grip_capture = true; result.push_back(UiAction::BeginRecord); break;
|
||||
case GripRecord::Change::End: grip_capture = false; result.push_back(UiAction::EndRecord); break;
|
||||
case GripRecord::Change::Cancel: grip_capture = false; result.push_back(UiAction::Cancel); break;
|
||||
case GripRecord::Change::None: break;
|
||||
}
|
||||
constexpr std::array<UiAction, 4> mapped{{UiAction::Record, UiAction::Cancel, UiAction::Insert, UiAction::Enter}};
|
||||
for (size_t i = 0; i < edges.size(); ++i) {
|
||||
auto [active, down] = digital(i + 2);
|
||||
if (i == 0 && (!active || !focus || !panel.enabled) && edges[i].reset()) {
|
||||
ptt_capture = false; result.push_back(UiAction::Cancel);
|
||||
}
|
||||
auto change = edges[i].update(active && focus && (panel.enabled || i == 1), down);
|
||||
if (i == 0) {
|
||||
if (change == NeutralEdge::Change::Down) {
|
||||
ptt_capture = true; result.push_back(UiAction::BeginRecord);
|
||||
} else if (change == NeutralEdge::Change::Up) {
|
||||
ptt_capture = false; result.push_back(UiAction::EndRecord);
|
||||
}
|
||||
} else if (change == NeutralEdge::Change::Down && available(mapped[i])) {
|
||||
result.push_back(mapped[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
Overlay::Overlay(const std::string& assets, const std::string& font, bool hand)
|
||||
: impl_(std::make_unique<Impl>(assets, font, hand)) {}
|
||||
Overlay::~Overlay() = default;
|
||||
std::vector<UiAction> Overlay::poll() { return impl_->poll(); }
|
||||
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
|
||||
std::string Overlay::controls_status() {
|
||||
auto left = impl_->digital(0), right = impl_->digital(1);
|
||||
return std::string("Grip actions tracked/active: left=") + (left.first ? "yes" : "no") +
|
||||
" right=" + (right.first ? "yes" : "no");
|
||||
}
|
||||
|
||||
int registration(const std::string& manifest, bool remove, bool autostart) {
|
||||
try {
|
||||
const auto path = absolute_file(manifest);
|
||||
// Utility initialization does not load hardware drivers. Call only for an
|
||||
// explicitly requested registration, never at static initialization.
|
||||
configure_registry();
|
||||
if (!vr::VR_IsRuntimeInstalled()) throw std::runtime_error("OpenVR runtime not installed");
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
auto* system = vr::VR_Init(&err, vr::VRApplication_Utility);
|
||||
if (err != vr::VRInitError_None || !system)
|
||||
throw std::runtime_error(std::string("OpenVR utility init: ") + vr::VR_GetVRInitErrorAsEnglishDescription(err));
|
||||
struct Shutdown { ~Shutdown() { vr::VR_Shutdown(); } } shutdown;
|
||||
auto* apps = vr::VRApplications();
|
||||
if (!apps) throw std::runtime_error("OpenVR applications interface unavailable");
|
||||
constexpr const char* key = "local.frameyap.overlay";
|
||||
if (remove) {
|
||||
// Only remove the supplied manifest; never touch other applications.
|
||||
auto e = apps->RemoveApplicationManifest(path.c_str());
|
||||
if (e != vr::VRApplicationError_None) throw std::runtime_error(apps->GetApplicationsErrorNameFromEnum(e));
|
||||
} else {
|
||||
auto e = apps->AddApplicationManifest(path.c_str(), false);
|
||||
if (e != vr::VRApplicationError_None) throw std::runtime_error(apps->GetApplicationsErrorNameFromEnum(e));
|
||||
if (autostart) {
|
||||
e = apps->SetApplicationAutoLaunch(key, true);
|
||||
if (e != vr::VRApplicationError_None) throw std::runtime_error(apps->GetApplicationsErrorNameFromEnum(e));
|
||||
}
|
||||
}
|
||||
if (apps->IsApplicationInstalled(key) == remove)
|
||||
throw std::runtime_error("OpenVR registration state did not match the requested operation");
|
||||
std::cout << (remove ? "Unregistered " : "Registered ") << key;
|
||||
if (!remove) std::cout << " (autolaunch=" << (apps->GetApplicationAutoLaunch(key) ? "on" : "off") << ")";
|
||||
std::cout << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "FrameYap registration: " << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} // namespace frameyap
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace frameyap {
|
||||
enum class UiAction { Toggle, Record, BeginRecord, EndRecord, Cancel, Insert, Enter, Quit };
|
||||
struct Panel {
|
||||
std::string status;
|
||||
std::string transcript;
|
||||
std::string detail;
|
||||
bool enabled = false;
|
||||
bool recording = false;
|
||||
};
|
||||
class Overlay {
|
||||
public:
|
||||
Overlay(const std::string& assets, const std::string& font, bool hand);
|
||||
~Overlay();
|
||||
Overlay(const Overlay&) = delete;
|
||||
Overlay& operator=(const Overlay&) = delete;
|
||||
std::vector<UiAction> poll();
|
||||
void draw(const Panel& panel);
|
||||
std::string controls_status(); // diagnostic only, no input delivery
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
// Explicit registration only; zero on success, nonzero on failure.
|
||||
int registration(const std::string& manifest, bool remove, bool autostart);
|
||||
} // namespace frameyap
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#include "runtime.hpp"
|
||||
#include "audio.hpp"
|
||||
#include "core.hpp"
|
||||
#include "overlay.hpp"
|
||||
#include "text_input.hpp"
|
||||
#include "worker.hpp"
|
||||
#include "instance_lock.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fcntl.h>
|
||||
#include <stdexcept>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace frameyap {
|
||||
namespace {
|
||||
volatile sig_atomic_t interrupted = 0;
|
||||
void signal_stop(int) { interrupted = 1; }
|
||||
std::string state_label(State state) {
|
||||
switch (state) {
|
||||
case State::Warming: return "Warming - on-device Redux CPU";
|
||||
case State::Ready: return "Ready - double-tap + hold right grip";
|
||||
case State::Recording: return "RECORDING";
|
||||
case State::Transcribing: return "Transcribing - on device";
|
||||
case State::Review: return "Review - Insert approves CURRENT focus";
|
||||
case State::Queued: return "Input queued - not a delivery receipt";
|
||||
case State::Error: return "Unavailable - Record to retry";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
int run(const Options& options) {
|
||||
InstanceLock lock;
|
||||
interrupted = 0;
|
||||
auto old_int = std::signal(SIGINT, signal_stop);
|
||||
auto old_term = std::signal(SIGTERM, signal_stop);
|
||||
struct Restore { decltype(old_int) a, b; ~Restore() { std::signal(SIGINT, a); std::signal(SIGTERM, b); } } restore{old_int, old_term};
|
||||
Overlay overlay(options.assets, options.font, options.hand);
|
||||
Worker worker;
|
||||
Audio audio;
|
||||
Session session;
|
||||
std::string detail = "Review mode. Other apps may also hear your mic. Enter is separate.";
|
||||
bool quit = false;
|
||||
auto warm = [&] {
|
||||
worker.stop(); session = Session{};
|
||||
worker.start(options.python, options.worker, options.model, options.threads);
|
||||
detail = "Loading local model; microphone closed. Record again when Ready.";
|
||||
};
|
||||
auto stop_record = [&] {
|
||||
if (session.state() != State::Recording) return;
|
||||
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).";
|
||||
std::fill(pcm.begin(), pcm.end(), 0.0f);
|
||||
};
|
||||
auto start_record = [&] {
|
||||
if (session.state() == State::Review || session.state() == State::Transcribing || session.state() == State::Warming) return;
|
||||
if (!worker.ready()) { warm(); return; }
|
||||
if (session.state() == State::Error) session.cancel();
|
||||
if (!session.record()) return;
|
||||
audio.start();
|
||||
detail = "Release grip to finish. Cancel discards. Maximum 20 seconds.";
|
||||
};
|
||||
try { warm(); }
|
||||
catch (const std::exception& e) { session.fail(); detail = e.what(); }
|
||||
while (!quit && !interrupted) {
|
||||
try {
|
||||
if (auto reply = worker.poll()) {
|
||||
if (reply->id == session.id() && session.state() == State::Transcribing) {
|
||||
if (!reply->error.empty()) throw std::runtime_error(reply->error);
|
||||
session.reply(reply->id, reply->text);
|
||||
detail = session.text().empty() ? "No speech recognized; try again." : "Focus your destination, then Insert. Cancel discards.";
|
||||
}
|
||||
}
|
||||
if (worker.ready()) session.ready();
|
||||
if (audio.recording() && audio.poll()) stop_record();
|
||||
} catch (const std::exception& e) {
|
||||
audio.cancel(); 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.
|
||||
}
|
||||
// Drawing precedes input polling: Enter is disabled until Ready is visible.
|
||||
Panel panel{state_label(session.state()), session.text(), detail,
|
||||
session.state() != State::Error && session.state() != State::Warming,
|
||||
session.state() == State::Recording};
|
||||
if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s";
|
||||
overlay.draw(panel);
|
||||
for (auto action : overlay.poll()) {
|
||||
try {
|
||||
switch (action) {
|
||||
case UiAction::Quit: quit = true; break;
|
||||
case UiAction::Toggle:
|
||||
case UiAction::Record:
|
||||
if (session.state() == State::Recording) stop_record(); else start_record();
|
||||
break;
|
||||
case UiAction::BeginRecord: start_record(); break;
|
||||
case UiAction::EndRecord: stop_record(); break;
|
||||
case UiAction::Cancel: {
|
||||
auto state = session.state();
|
||||
audio.cancel(); session.cancel(); detail = "Discarded. Microphone closed.";
|
||||
if (state == State::Warming || state == State::Transcribing || state == State::Error) {
|
||||
worker.stop(); session.fail(); detail = "Cancelled. Record to reload local worker.";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UiAction::Insert:
|
||||
if (session.state() == State::Review) {
|
||||
// Acquire IME before consuming: unavailable means the preview remains.
|
||||
TextInput input(options.socket);
|
||||
if (interrupted) break;
|
||||
auto literal = session.take_insert();
|
||||
try { input.text(*literal); detail = "Input queued to current focus. Enter remains a separate action."; }
|
||||
catch (...) { detail = "Delivery uncertain; not retried. Check destination before dictating again."; }
|
||||
}
|
||||
break;
|
||||
case UiAction::Enter:
|
||||
if (session.state() == State::Ready || session.state() == State::Queued || session.state() == State::Review) {
|
||||
TextInput input(options.socket);
|
||||
if (interrupted) break;
|
||||
try { input.enter(); detail = "Explicit Enter queued to current focus."; }
|
||||
catch (...) { detail = "Enter delivery uncertain; not retried."; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
// Input lease failures preserve preview; capture failures release mic.
|
||||
if (session.state() == State::Recording || session.state() == State::Transcribing) {
|
||||
audio.cancel(); worker.stop(); session.fail();
|
||||
}
|
||||
detail = e.what();
|
||||
}
|
||||
if (quit || interrupted) break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
audio.cancel(); session.cancel(); worker.stop();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
namespace frameyap {
|
||||
struct Options {
|
||||
std::string assets, font, python = "python3", worker, model, socket;
|
||||
int threads = 2;
|
||||
bool hand = true;
|
||||
};
|
||||
int run(const Options& options);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "text_input.hpp"
|
||||
#include "core.hpp"
|
||||
#include "gamescope-input-method-client.h"
|
||||
#include <wayland-client.h>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <poll.h>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace frameyap {
|
||||
struct TextInput::Impl {
|
||||
wl_display* display = nullptr;
|
||||
wl_registry* registry = nullptr;
|
||||
wl_seat* seat = nullptr;
|
||||
gamescope_input_method_manager* manager = nullptr;
|
||||
gamescope_input_method* ime = nullptr;
|
||||
uint32_t serial = 0;
|
||||
uint32_t seat_name = 0, manager_name = 0;
|
||||
bool done = false, unavailable = false, used = false, ambiguous = false;
|
||||
~Impl() {
|
||||
// Local proxy destruction then socket close avoids a blocking flush on teardown.
|
||||
if (ime) wl_proxy_destroy(reinterpret_cast<wl_proxy*>(ime));
|
||||
if (manager) wl_proxy_destroy(reinterpret_cast<wl_proxy*>(manager));
|
||||
if (seat) wl_seat_destroy(seat);
|
||||
if (registry) wl_registry_destroy(registry);
|
||||
if (display) wl_display_disconnect(display);
|
||||
}
|
||||
static void global(void* data, wl_registry* registry, uint32_t name, const char* interface, uint32_t version) {
|
||||
auto& self = *static_cast<Impl*>(data);
|
||||
if (std::strcmp(interface, "wl_seat") == 0) {
|
||||
if (self.seat) { self.ambiguous = true; return; }
|
||||
self.seat_name = name;
|
||||
self.seat = static_cast<wl_seat*>(wl_registry_bind(registry, name, &wl_seat_interface, 1));
|
||||
}
|
||||
if (std::strcmp(interface, "gamescope_input_method_manager") == 0 && version >= 2) {
|
||||
if (self.manager) { self.ambiguous = true; return; }
|
||||
self.manager_name = name;
|
||||
self.manager = static_cast<gamescope_input_method_manager*>(wl_registry_bind(registry, name, &gamescope_input_method_manager_interface, 2));
|
||||
}
|
||||
}
|
||||
static void removed(void* data, wl_registry*, uint32_t name) {
|
||||
auto& self = *static_cast<Impl*>(data);
|
||||
if (name == self.seat_name || name == self.manager_name) self.unavailable = true;
|
||||
}
|
||||
static void unavailable_event(void* data, gamescope_input_method*) { static_cast<Impl*>(data)->unavailable = true; }
|
||||
static void done_event(void* data, gamescope_input_method*, uint32_t serial) {
|
||||
auto& self = *static_cast<Impl*>(data); self.serial = serial; self.done = true;
|
||||
}
|
||||
static void synced(void* data, wl_callback*, uint32_t) { *static_cast<bool*>(data) = true; }
|
||||
void roundtrip() {
|
||||
bool complete = false;
|
||||
wl_callback* callback = wl_display_sync(display);
|
||||
if (!callback) throw std::runtime_error("Gamescope sync allocation failed");
|
||||
const wl_callback_listener listener{synced};
|
||||
wl_callback_add_listener(callback, &listener, &complete);
|
||||
auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(1000);
|
||||
try {
|
||||
while (!complete) {
|
||||
if (wl_display_dispatch_pending(display) < 0) throw std::runtime_error("Gamescope disconnected");
|
||||
if (complete) break;
|
||||
int flushed = wl_display_flush(display);
|
||||
if (flushed < 0 && errno != EAGAIN) throw std::runtime_error("Gamescope flush failed");
|
||||
while (wl_display_prepare_read(display) != 0) {
|
||||
if (wl_display_dispatch_pending(display) < 0) throw std::runtime_error("Gamescope disconnected");
|
||||
}
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - std::chrono::steady_clock::now()).count();
|
||||
pollfd fd{wl_display_get_fd(display), short(POLLIN | (flushed < 0 ? POLLOUT : 0)), 0};
|
||||
int result = ms > 0 ? ::poll(&fd, 1, static_cast<int>(ms)) : 0;
|
||||
if (result <= 0 || (fd.revents & (POLLERR | POLLHUP | POLLNVAL))) {
|
||||
wl_display_cancel_read(display);
|
||||
if (result < 0 && errno == EINTR) continue;
|
||||
throw std::runtime_error("Gamescope unavailable or timed out");
|
||||
}
|
||||
if (fd.revents & POLLIN) {
|
||||
if (wl_display_read_events(display) < 0) throw std::runtime_error("Gamescope read failed");
|
||||
} else wl_display_cancel_read(display);
|
||||
}
|
||||
} catch (...) { wl_callback_destroy(callback); throw; }
|
||||
wl_callback_destroy(callback);
|
||||
if (unavailable) throw std::runtime_error("Gamescope IME unavailable (Steam keyboard may own it)");
|
||||
}
|
||||
void check_authorization() const {
|
||||
if (used || unavailable || !done) throw std::runtime_error("Input authorization is no longer valid");
|
||||
}
|
||||
void commit() {
|
||||
check_authorization();
|
||||
used = true;
|
||||
gamescope_input_method_commit(ime, serial);
|
||||
// A roundtrip only acknowledges compositor processing, not target consumption.
|
||||
roundtrip();
|
||||
}
|
||||
};
|
||||
TextInput::TextInput(const std::string& socket) : impl_(std::make_unique<Impl>()) {
|
||||
if (socket.empty()) throw std::runtime_error("Specify the Gamescope Wayland socket with --socket");
|
||||
auto& p = *impl_;
|
||||
p.display = wl_display_connect(socket.c_str());
|
||||
if (!p.display) throw std::runtime_error("Cannot connect to Gamescope socket; check --socket");
|
||||
p.registry = wl_display_get_registry(p.display);
|
||||
if (!p.registry) throw std::runtime_error("Cannot get Gamescope registry");
|
||||
static const wl_registry_listener registry_listener{Impl::global, Impl::removed};
|
||||
wl_registry_add_listener(p.registry, ®istry_listener, &p);
|
||||
p.roundtrip();
|
||||
if (!p.seat || !p.manager || p.ambiguous)
|
||||
throw std::runtime_error("Gamescope IME v2/unique seat unavailable on this socket");
|
||||
p.ime = gamescope_input_method_manager_create_input_method(p.manager, p.seat);
|
||||
if (!p.ime) throw std::runtime_error("Cannot create Gamescope IME");
|
||||
static const gamescope_input_method_listener ime_listener{Impl::unavailable_event, Impl::done_event};
|
||||
gamescope_input_method_add_listener(p.ime, &ime_listener, &p);
|
||||
p.roundtrip();
|
||||
if (!p.done) throw std::runtime_error("Gamescope IME not ready");
|
||||
}
|
||||
TextInput::~TextInput() = default;
|
||||
void TextInput::text(const std::string& literal) {
|
||||
auto validated = literal_text(literal);
|
||||
if (validated.empty()) throw std::runtime_error("No text to insert");
|
||||
impl_->check_authorization();
|
||||
gamescope_input_method_set_string(impl_->ime, validated.c_str());
|
||||
impl_->commit();
|
||||
}
|
||||
void TextInput::enter() {
|
||||
impl_->check_authorization();
|
||||
gamescope_input_method_set_action(impl_->ime, GAMESCOPE_INPUT_METHOD_ACTION_SUBMIT);
|
||||
impl_->commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <string>
|
||||
namespace frameyap {
|
||||
// Short-lived, exclusive IME lease acquired only for an explicit user action.
|
||||
// Construction checks protocol readiness without delivering input.
|
||||
class TextInput {
|
||||
public:
|
||||
explicit TextInput(const std::string& socket);
|
||||
~TextInput();
|
||||
TextInput(const TextInput&) = delete;
|
||||
TextInput& operator=(const TextInput&) = delete;
|
||||
void text(const std::string& literal);
|
||||
void enter();
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -22,14 +22,14 @@ foreach(mode IN ITEMS default help version invalid extra)
|
||||
message(FATAL_ERROR "${mode}: exit ${result}, expected ${expected_exit}: ${error}")
|
||||
endif()
|
||||
if(mode STREQUAL "version")
|
||||
if(NOT output STREQUAL "frame-dictation ${EXPECTED_VERSION} (scaffold)\n")
|
||||
if(NOT output STREQUAL "frameyap ${EXPECTED_VERSION}\n")
|
||||
message(FATAL_ERROR "Unexpected version: ${output}")
|
||||
endif()
|
||||
elseif(expected_exit EQUAL 2)
|
||||
if(NOT error MATCHES "Unsupported arguments")
|
||||
message(FATAL_ERROR "Missing rejection diagnostic")
|
||||
endif()
|
||||
elseif(NOT output MATCHES "Scaffold only")
|
||||
message(FATAL_ERROR "Missing scaffold status")
|
||||
elseif(NOT output MATCHES "No device access unless")
|
||||
message(FATAL_ERROR "Missing explicit runtime opt-in status")
|
||||
endif()
|
||||
endforeach()
|
||||
@@ -0,0 +1,73 @@
|
||||
#include "gestures.hpp"
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
using namespace frameyap;
|
||||
using namespace std::chrono_literals;
|
||||
#define CHECK(x) do { if (!(x)) { std::cerr << __FILE__ << ':' << __LINE__ << " CHECK failed: " #x << '\n'; std::exit(1); } } while (false)
|
||||
int main() {
|
||||
using Clock = DoubleTap::Clock;
|
||||
const auto t = Clock::time_point{};
|
||||
DoubleTap left;
|
||||
auto l = [&](bool active, bool down, bool enabled, int ms) {
|
||||
return left.update(active, down, enabled, t + std::chrono::milliseconds(ms));
|
||||
};
|
||||
CHECK(!l(true, true, true, 0)); // held on reconnect must rearm
|
||||
CHECK(!l(true, false, true, 10));
|
||||
CHECK(!l(true, true, true, 20));
|
||||
CHECK(!l(true, false, true, 270)); // inclusive 250ms
|
||||
CHECK(!l(true, true, true, 620)); // inclusive 350ms
|
||||
CHECK(l(true, false, true, 870));
|
||||
CHECK(!l(true, true, true, 880));
|
||||
CHECK(!l(true, false, true, 890));
|
||||
CHECK(!l(true, true, true, 1241)); // late second press
|
||||
CHECK(!l(true, false, true, 1250));
|
||||
CHECK(!l(true, true, true, 1260));
|
||||
CHECK(!l(true, false, true, 1511)); // long second press: no Enter
|
||||
CHECK(!l(false, false, true, 1520));
|
||||
CHECK(!l(true, true, true, 1530));
|
||||
CHECK(!l(true, false, true, 1540));
|
||||
CHECK(!l(true, true, false, 1550));
|
||||
CHECK(!l(true, false, true, 1560));
|
||||
|
||||
using R = GripRecord::Change;
|
||||
GripRecord right;
|
||||
auto r = [&](bool active, bool down, int ms) {
|
||||
return right.update(active, down, t + std::chrono::milliseconds(ms));
|
||||
};
|
||||
CHECK(r(true, true, 0) == R::None);
|
||||
CHECK(r(true, false, 10) == R::None);
|
||||
CHECK(r(true, true, 20) == R::None);
|
||||
CHECK(r(true, false, 270) == R::None);
|
||||
CHECK(r(true, true, 620) == R::Begin);
|
||||
CHECK(r(true, true, 99999) == R::None); // no accidental Enter or stop on long hold
|
||||
CHECK(r(true, false, 100000) == R::End);
|
||||
CHECK(r(true, true, 100010) == R::None);
|
||||
CHECK(r(true, false, 100011) == R::None);
|
||||
CHECK(r(true, true, 100362) == R::None);
|
||||
CHECK(r(true, false, 100370) == R::None);
|
||||
CHECK(r(true, true, 100380) == R::Begin);
|
||||
CHECK(r(false, true, 100381) == R::Cancel); // lost pose/activity while held
|
||||
CHECK(r(true, true, 100382) == R::None); // reconnect held cannot finish/start
|
||||
CHECK(r(true, false, 100383) == R::None);
|
||||
CHECK(r(true, true, 100390) == R::None);
|
||||
CHECK(r(true, false, 100641) == R::None); // first squeeze too long
|
||||
CHECK(r(true, true, 100650) == R::None);
|
||||
CHECK(r(true, false, 100660) == R::None);
|
||||
CHECK(r(true, true, 101010) == R::Begin);
|
||||
CHECK(right.reset() == R::Cancel); // overlay focus lost
|
||||
CHECK(right.reset() == R::None);
|
||||
CHECK(r(true, true, 101020) == R::None);
|
||||
CHECK(r(true, false, 101030) == R::None);
|
||||
|
||||
NeutralEdge e;
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, false) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::Down);
|
||||
CHECK(e.reset()); // PTT activity/tracking/focus loss cancels capture
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, false) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::Down);
|
||||
CHECK(e.update(true, false) == NeutralEdge::Change::Up);
|
||||
CHECK(!e.reset());
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#include "instance_lock.hpp"
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
int main() {
|
||||
char pattern[] = "/tmp/frameyap-lock-test-XXXXXX";
|
||||
char* dir = mkdtemp(pattern);
|
||||
if (!dir) return 1;
|
||||
setenv("FRAMEYAP_INSTALL_ROOT", dir, 1);
|
||||
try {
|
||||
auto first = std::make_unique<frameyap::InstanceLock>();
|
||||
bool rejected = false;
|
||||
try { frameyap::InstanceLock second; } catch (const std::runtime_error&) { rejected = true; }
|
||||
if (!rejected) throw std::runtime_error("second instance acquired lock");
|
||||
first.reset();
|
||||
{ frameyap::InstanceLock next; }
|
||||
auto lock = std::filesystem::path(dir) / ".lock";
|
||||
std::filesystem::remove(lock);
|
||||
std::filesystem::create_symlink("foreign", lock);
|
||||
rejected = false;
|
||||
try { frameyap::InstanceLock unsafe; } catch (const std::runtime_error&) { rejected = true; }
|
||||
if (!rejected) throw std::runtime_error("followed unsafe lock symlink");
|
||||
std::filesystem::remove(lock);
|
||||
std::filesystem::remove(dir);
|
||||
std::cout << "instance lock checks passed\n";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
std::filesystem::remove_all(dir);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#include "core.hpp"
|
||||
#include "text_input.hpp"
|
||||
#include "gamescope-input-method-server.h"
|
||||
#include <wayland-server.h>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
using namespace frameyap;
|
||||
using namespace std::chrono_literals;
|
||||
#define CHECK(condition) do { if (!(condition)) throw std::runtime_error( \
|
||||
std::string("line ") + std::to_string(__LINE__) + ": " #condition); } while (false)
|
||||
|
||||
template <typename F> void throws(F&& f) {
|
||||
try { f(); } catch (const std::runtime_error&) { return; }
|
||||
CHECK(false);
|
||||
}
|
||||
|
||||
// This fixture never connects to the ambient compositor: the client receives only
|
||||
// this private socket name, scoped to a freshly-created 0700 runtime directory.
|
||||
class RuntimeDir {
|
||||
public:
|
||||
RuntimeDir() {
|
||||
if (const char* old = std::getenv("XDG_RUNTIME_DIR")) { prior_ = old; had_prior_ = true; }
|
||||
char pattern[] = "/tmp/frameyap-wayland-test-XXXXXX";
|
||||
char* created = mkdtemp(pattern);
|
||||
if (!created) throw std::runtime_error("mkdtemp failed");
|
||||
path_ = created;
|
||||
struct stat st{};
|
||||
if (stat(path_.c_str(), &st) != 0 || (st.st_mode & 0777) != 0700) {
|
||||
rmdir(path_.c_str());
|
||||
throw std::runtime_error("runtime directory is not private");
|
||||
}
|
||||
if (setenv("XDG_RUNTIME_DIR", path_.c_str(), 1)) {
|
||||
rmdir(path_.c_str());
|
||||
throw std::runtime_error("setenv failed");
|
||||
}
|
||||
}
|
||||
~RuntimeDir() {
|
||||
if (had_prior_) setenv("XDG_RUNTIME_DIR", prior_.c_str(), 1);
|
||||
else unsetenv("XDG_RUNTIME_DIR");
|
||||
unlink((path_ + "/frameyap-fake").c_str());
|
||||
unlink((path_ + "/frameyap-fake.lock").c_str());
|
||||
rmdir(path_.c_str());
|
||||
}
|
||||
RuntimeDir(const RuntimeDir&) = delete;
|
||||
RuntimeDir& operator=(const RuntimeDir&) = delete;
|
||||
private:
|
||||
std::string path_, prior_;
|
||||
bool had_prior_ = false;
|
||||
};
|
||||
|
||||
struct Event {
|
||||
std::string kind, text;
|
||||
uint32_t number = 0;
|
||||
};
|
||||
struct Snapshot {
|
||||
int creates = 0, destroyed = 0;
|
||||
bool bad_seat = false;
|
||||
std::vector<Event> events;
|
||||
};
|
||||
class FakeServer {
|
||||
public:
|
||||
enum class Mode { Ready, Unavailable, Stalled };
|
||||
explicit FakeServer(Mode mode = Mode::Ready) : mode_(mode) {
|
||||
display_.reset(wl_display_create());
|
||||
CHECK(display_ != nullptr);
|
||||
CHECK(wl_display_add_socket(display_.get(), "frameyap-fake") == 0);
|
||||
CHECK(wl_global_create(display_.get(), &wl_seat_interface, 1, this, bind_seat));
|
||||
CHECK(wl_global_create(display_.get(), &gamescope_input_method_manager_interface,
|
||||
2, this, bind_manager));
|
||||
thread_ = std::thread([this] {
|
||||
if (mode_ == Mode::Stalled) {
|
||||
while (!stop_) std::this_thread::sleep_for(5ms);
|
||||
return;
|
||||
}
|
||||
while (!stop_) {
|
||||
wl_event_loop_dispatch(wl_display_get_event_loop(display_.get()), 10);
|
||||
wl_display_flush_clients(display_.get());
|
||||
}
|
||||
});
|
||||
}
|
||||
~FakeServer() {
|
||||
stop_ = true;
|
||||
if (thread_.joinable()) thread_.join();
|
||||
// Destroy client resources while callback state/mutex are still alive,
|
||||
// including on a test assertion failure.
|
||||
wl_display_destroy_clients(display_.get());
|
||||
display_.reset();
|
||||
}
|
||||
FakeServer(const FakeServer&) = delete;
|
||||
FakeServer& operator=(const FakeServer&) = delete;
|
||||
Snapshot snapshot() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return state_;
|
||||
}
|
||||
bool wait_destroyed(int count) {
|
||||
std::unique_lock lock(mutex_);
|
||||
return changed_.wait_for(lock, 1s, [&] { return state_.destroyed >= count; });
|
||||
}
|
||||
static constexpr const char* socket = "frameyap-fake";
|
||||
private:
|
||||
struct DisplayDeleter { void operator()(wl_display* p) const { if (p) wl_display_destroy(p); } };
|
||||
RuntimeDir runtime_;
|
||||
std::unique_ptr<wl_display, DisplayDeleter> display_;
|
||||
Mode mode_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread thread_;
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable changed_;
|
||||
Snapshot state_;
|
||||
|
||||
static FakeServer& owner(wl_resource* r) {
|
||||
return *static_cast<FakeServer*>(wl_resource_get_user_data(r));
|
||||
}
|
||||
static void bind_seat(wl_client* client, void* data, uint32_t version, uint32_t id) {
|
||||
auto* r = wl_resource_create(client, &wl_seat_interface, version, id);
|
||||
if (!r) { wl_client_post_no_memory(client); return; }
|
||||
wl_resource_set_implementation(r, nullptr, data, nullptr);
|
||||
}
|
||||
static void bind_manager(wl_client* client, void* data, uint32_t version, uint32_t id) {
|
||||
auto* r = wl_resource_create(client, &gamescope_input_method_manager_interface, version, id);
|
||||
if (!r) { wl_client_post_no_memory(client); return; }
|
||||
static const struct gamescope_input_method_manager_interface methods{destroy, create};
|
||||
wl_resource_set_implementation(r, &methods, data, nullptr);
|
||||
}
|
||||
static void destroy(wl_client*, wl_resource* r) { wl_resource_destroy(r); }
|
||||
static void create(wl_client* client, wl_resource* manager, wl_resource* seat, uint32_t id) {
|
||||
auto& server = owner(manager);
|
||||
auto* r = wl_resource_create(client, &gamescope_input_method_interface,
|
||||
wl_resource_get_version(manager), id);
|
||||
if (!r) { wl_client_post_no_memory(client); return; }
|
||||
static const struct gamescope_input_method_interface methods{
|
||||
destroy, commit, set_string, set_action, nullptr, nullptr, nullptr, nullptr};
|
||||
wl_resource_set_implementation(r, &methods, &server, ime_destroyed);
|
||||
{
|
||||
std::lock_guard lock(server.mutex_);
|
||||
++server.state_.creates;
|
||||
if (!seat || wl_resource_get_client(seat) != client ||
|
||||
std::strcmp(wl_resource_get_class(seat), "wl_seat") != 0 ||
|
||||
wl_resource_get_version(manager) != 2) server.state_.bad_seat = true;
|
||||
}
|
||||
if (server.mode_ == Mode::Ready) gamescope_input_method_send_done(r, 42);
|
||||
else gamescope_input_method_send_unavailable(r);
|
||||
}
|
||||
static void ime_destroyed(wl_resource* r) {
|
||||
auto& server = owner(r);
|
||||
{
|
||||
std::lock_guard lock(server.mutex_);
|
||||
++server.state_.destroyed;
|
||||
}
|
||||
server.changed_.notify_all();
|
||||
}
|
||||
static void commit(wl_client*, wl_resource* r, uint32_t serial) {
|
||||
auto& server = owner(r);
|
||||
std::lock_guard lock(server.mutex_);
|
||||
server.state_.events.push_back({"commit", "", serial});
|
||||
}
|
||||
static void set_string(wl_client*, wl_resource* r, const char* text) {
|
||||
auto& server = owner(r);
|
||||
std::lock_guard lock(server.mutex_);
|
||||
server.state_.events.push_back({"string", text, 0});
|
||||
}
|
||||
static void set_action(wl_client*, wl_resource* r, uint32_t action) {
|
||||
auto& server = owner(r);
|
||||
std::lock_guard lock(server.mutex_);
|
||||
server.state_.events.push_back({"action", "", action});
|
||||
}
|
||||
};
|
||||
|
||||
void test_text() {
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
auto initial = server.snapshot();
|
||||
CHECK(initial.creates == 1 && !initial.bad_seat && initial.events.empty());
|
||||
input.text("Hello 世界 😀\r\nnext\tline\xe2\x80\xa8 end");
|
||||
auto state = server.snapshot();
|
||||
CHECK(state.events.size() == 2);
|
||||
CHECK(state.events[0].kind == "string");
|
||||
CHECK(state.events[0].text == "Hello 世界 😀 next line end");
|
||||
CHECK(state.events[1].kind == "commit" && state.events[1].number == 42);
|
||||
// Authorization is consumed even when a duplicate delivery is attempted.
|
||||
throws([&] { input.enter(); });
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
|
||||
void test_enter() {
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
CHECK(server.snapshot().events.empty());
|
||||
input.enter();
|
||||
auto state = server.snapshot();
|
||||
CHECK(state.events.size() == 2);
|
||||
CHECK(state.events[0].kind == "action" &&
|
||||
state.events[0].number == GAMESCOPE_INPUT_METHOD_ACTION_SUBMIT);
|
||||
CHECK(state.events[1].kind == "commit" && state.events[1].number == 42);
|
||||
throws([&] { input.text("duplicate"); });
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
|
||||
void test_invalid() {
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
throws([&] { input.text(std::string("x\0y", 3)); });
|
||||
throws([&] { input.text("\x1b[31m"); });
|
||||
throws([&] { input.text("\xc0\xaf"); });
|
||||
throws([&] { input.text("\xed\xa0\x80"); });
|
||||
throws([&] { input.text("\xf4\x90\x80\x80"); });
|
||||
throws([&] { input.text("\xe2\x82"); });
|
||||
throws([&] { input.text("\xc2\x85"); });
|
||||
throws([&] { input.text(std::string(4097, 'a')); });
|
||||
throws([&] { input.text("\t\n"); });
|
||||
CHECK(server.snapshot().events.empty());
|
||||
input.text("submit; $(echo x)");
|
||||
auto events = server.snapshot().events;
|
||||
CHECK(events.size() == 2 && events[0].text == "submit; $(echo x)");
|
||||
CHECK(events[1].kind == "commit");
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
}
|
||||
|
||||
void test_review_insert() {
|
||||
Session session;
|
||||
session.ready();
|
||||
CHECK(session.record() && session.finish(16000));
|
||||
auto id = session.id();
|
||||
CHECK(session.reply(id, "send\n世界"));
|
||||
CHECK(session.state() == State::Review);
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
CHECK(server.snapshot().events.empty()); // Discovery is not insertion.
|
||||
auto approved = session.take_insert();
|
||||
CHECK(approved == "send 世界" && !session.take_insert());
|
||||
CHECK(!session.reply(id, "duplicate"));
|
||||
input.text(*approved);
|
||||
auto events = server.snapshot().events;
|
||||
CHECK(events.size() == 2 && events[0].kind == "string");
|
||||
CHECK(events[0].text == "send 世界");
|
||||
CHECK(events[1].kind == "commit" && events[1].number == 42);
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
}
|
||||
|
||||
void test_unavailable() {
|
||||
FakeServer server(FakeServer::Mode::Unavailable);
|
||||
throws([&] { TextInput input(server.socket); });
|
||||
CHECK(server.wait_destroyed(1));
|
||||
auto state = server.snapshot();
|
||||
CHECK(state.creates == 1 && state.events.empty());
|
||||
}
|
||||
|
||||
void test_stalled() {
|
||||
FakeServer server(FakeServer::Mode::Stalled);
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
throws([&] { TextInput input(server.socket); });
|
||||
auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
CHECK(elapsed >= 700ms && elapsed < 4s);
|
||||
CHECK(server.snapshot().creates == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_text(); test_enter(); test_invalid(); test_review_insert();
|
||||
test_unavailable(); test_stalled();
|
||||
std::cout << "native fake Wayland input checks passed\n";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "text_input_test: " << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user