feat(runtime): integrate backend controls and guarded paced text delivery

This commit is contained in:
baketnk committed 2026-09-24 23:23:11 -04:00
1 parent 1321ca8316
commit 9829d0611d
26 files changed
+2238 -399

No files matched your search

+73 -27
View File
@@ -1,30 +1,36 @@
cmake_minimum_required(VERSION 3.20)
project(frameyap LANGUAGES C CXX)
if(NOT DEFINED FRAMEYAP_VERSION OR FRAMEYAP_VERSION STREQUAL "")
# An ISO-like UTC build stamp is both readable and safe in archive filenames.
# SOURCE_DATE_EPOCH is honored by CMake's TIMESTAMP for reproducible builds.
string(TIMESTAMP FRAMEYAP_VERSION "%Y-%m-%dT%H%M%SZ" UTC)
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --show-toplevel
OUTPUT_VARIABLE FRAMEYAP_GIT_ROOT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(FRAMEYAP_GIT_ROOT STREQUAL CMAKE_CURRENT_SOURCE_DIR)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --short=8 HEAD
OUTPUT_VARIABLE FRAMEYAP_GIT_SHA OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(FRAMEYAP_GIT_SHA MATCHES "^[0-9a-f]+$")
string(APPEND FRAMEYAP_VERSION "-g${FRAMEYAP_GIT_SHA}")
endif()
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" status --porcelain --untracked-files=no
OUTPUT_VARIABLE FRAMEYAP_GIT_DIRTY OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
# CMake honors SOURCE_DATE_EPOCH for reproducible UTC timestamps.
string(TIMESTAMP FRAMEYAP_BUILD_STAMP "%Y%m%d%H%M" UTC)
set(FRAMEYAP_VERSION "0.1.${FRAMEYAP_BUILD_STAMP}")
endif()
# The patch is exactly YYYYMMDDHHMM, not a git suffix or a semver prerelease.
if(NOT FRAMEYAP_VERSION MATCHES "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.[1-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$")
message(FATAL_ERROR "FRAMEYAP_VERSION must be MAJOR.MINOR.YYYYMMDDHHMM (numeric, no leading zeros)")
endif()
set(FRAMEYAP_GIT_INFO "")
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --show-toplevel
OUTPUT_VARIABLE FRAMEYAP_GIT_ROOT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(FRAMEYAP_GIT_ROOT STREQUAL CMAKE_CURRENT_SOURCE_DIR)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --short=8 HEAD
OUTPUT_VARIABLE FRAMEYAP_GIT_SHA OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" status --porcelain --untracked-files=normal
OUTPUT_VARIABLE FRAMEYAP_GIT_DIRTY OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}" tag --points-at HEAD
OUTPUT_VARIABLE FRAMEYAP_HEAD_TAGS OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
string(REPLACE "\n" ";" FRAMEYAP_HEAD_TAGS "${FRAMEYAP_HEAD_TAGS}")
if(FRAMEYAP_GIT_SHA MATCHES "^[0-9a-f]+$" AND
(FRAMEYAP_GIT_DIRTY OR NOT "v${FRAMEYAP_VERSION}" IN_LIST FRAMEYAP_HEAD_TAGS))
set(FRAMEYAP_GIT_INFO "git ${FRAMEYAP_GIT_SHA}")
if(FRAMEYAP_GIT_DIRTY)
string(APPEND FRAMEYAP_VERSION "-dirty")
string(APPEND FRAMEYAP_GIT_INFO " (uncommitted changes)")
endif()
endif()
endif()
endif()
if(NOT FRAMEYAP_VERSION MATCHES "^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z(-g[0-9a-f]+)?(-dirty)?$")
message(FATAL_ERROR "FRAMEYAP_VERSION must be an ISO-like UTC timestamp: YYYY-MM-DDTHHMMSSZ[-gHASH][-dirty]")
endif()
message(STATUS "FrameYap build version: ${FRAMEYAP_VERSION}")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -42,9 +48,15 @@ endif()
add_library(frameyap_core src/core.cpp)
target_include_directories(frameyap_core PUBLIC src)
add_executable(frameyap src/main.cpp)
add_library(frameyap_paced src/paced_delivery.cpp)
target_link_libraries(frameyap_paced PUBLIC frameyap_core)
target_include_directories(frameyap_paced PUBLIC src)
add_executable(frameyap src/main.cpp src/cli.cpp src/check.cpp src/model_status.cpp)
target_link_libraries(frameyap PRIVATE frameyap_core)
target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${FRAMEYAP_VERSION}")
target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${FRAMEYAP_VERSION}"
FRAMEYAP_GIT_INFO="${FRAMEYAP_GIT_INFO}"
FRAMEYAP_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}"
FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}")
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp)
target_include_directories(frameyap_mount PUBLIC src)
@@ -53,7 +65,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
endif()
if(FRAMEYAP_NATIVE)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(FATAL_ERROR "Native POC supports Linux only")
message(FATAL_ERROR "Native runtime supports Linux only")
endif()
find_package(PkgConfig REQUIRED)
find_package(Vulkan REQUIRED)
@@ -75,7 +87,7 @@ if(FRAMEYAP_NATIVE)
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")
message(FATAL_ERROR "Native runtime 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")
@@ -85,30 +97,49 @@ if(FRAMEYAP_NATIVE)
add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c"
COMMAND "${WAYLAND_SCANNER}" private-code "${PROTOCOL}" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c"
DEPENDS "${PROTOCOL}" VERBATIM)
target_sources(frameyap PRIVATE src/runtime.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp src/focus_guard.cpp
target_sources(frameyap PRIVATE src/runtime.cpp src/controller.cpp src/backend_manager.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp src/focus_guard.cpp
"${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h")
target_include_directories(frameyap PRIVATE "${OPENVR_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(frameyap PRIVATE frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}")
target_link_libraries(frameyap PRIVATE frameyap_paced frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}")
target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1)
set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib")
endif()
install(TARGETS frameyap RUNTIME DESTINATION bin)
# BackendManager discovers the self-contained installer at <root>/bin/install.sh.
# Its Python payload is embedded; scripts/install_payload.py is producer source,
# not a runtime dependency of the staged installer.
install(PROGRAMS install.sh DESTINATION bin)
install(DIRECTORY assets/ DESTINATION assets)
install(DIRECTORY python/frameyap DESTINATION python FILES_MATCHING PATTERN "*.py" PATTERN "__pycache__" EXCLUDE)
install(FILES scripts/model-status.py scripts/fetch-model.py scripts/backend-service.py DESTINATION scripts)
include(CTest)
if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
find_package(Python3 3.10 COMPONENTS Interpreter)
add_test(NAME frameyap.cli COMMAND "${CMAKE_COMMAND}" "-DAPP=$<TARGET_FILE:frameyap>"
"-DEXPECTED_VERSION=${FRAMEYAP_VERSION}" -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/cli.cmake")
if(Python3_FOUND)
add_test(NAME frameyap.model_status COMMAND "${CMAKE_COMMAND}" "-DAPP=$<TARGET_FILE:frameyap>"
"-DROOT=${CMAKE_CURRENT_SOURCE_DIR}" "-DSTAGE=${CMAKE_CURRENT_BINARY_DIR}/model-status-test"
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/model_status.cmake")
endif()
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_audio_test tests/audio_test.cpp src/audio.cpp)
target_include_directories(frameyap_audio_test PRIVATE src tests/fake_sdl)
target_compile_options(frameyap_audio_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.audio_lifecycle COMMAND frameyap_audio_test)
add_executable(frameyap_cli_test tests/cli_test.cpp src/cli.cpp)
target_include_directories(frameyap_cli_test PRIVATE src)
target_compile_options(frameyap_cli_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.cli_parser COMMAND frameyap_cli_test)
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)
add_executable(frameyap_paced_delivery_test tests/paced_delivery_test.cpp)
target_link_libraries(frameyap_paced_delivery_test PRIVATE frameyap_paced)
target_compile_options(frameyap_paced_delivery_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.paced_delivery COMMAND frameyap_paced_delivery_test)
if(TARGET frameyap_panel)
add_executable(frameyap_panel_test tests/panel_test.cpp)
target_link_libraries(frameyap_panel_test PRIVATE frameyap_panel)
@@ -120,6 +151,17 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
target_link_libraries(frameyap_config_test PRIVATE frameyap_config)
target_compile_options(frameyap_config_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.config COMMAND frameyap_config_test "${CMAKE_CURRENT_SOURCE_DIR}/assets")
if(Python3_FOUND)
add_executable(frameyap_backend_manager_test tests/backend_manager_test.cpp src/backend_manager.cpp)
target_include_directories(frameyap_backend_manager_test PRIVATE src)
target_compile_options(frameyap_backend_manager_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.backend_manager COMMAND frameyap_backend_manager_test)
endif()
add_executable(frameyap_controller_test tests/controller_test.cpp src/controller.cpp)
target_link_libraries(frameyap_controller_test PRIVATE frameyap_paced)
target_include_directories(frameyap_controller_test PRIVATE src)
target_compile_options(frameyap_controller_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.controller COMMAND frameyap_controller_test)
add_executable(frameyap_mount_test tests/mount_test.cpp)
target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount)
add_test(NAME frameyap.mount COMMAND frameyap_mount_test)
@@ -143,7 +185,6 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
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)
add_executable(frameyap_focus_guard_test tests/focus_guard_test.cpp src/focus_guard.cpp)
target_include_directories(frameyap_focus_guard_test PRIVATE src)
@@ -177,12 +218,17 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
"${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)
target_link_libraries(frameyap_text_input_test PRIVATE frameyap_paced 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)
foreach(test IN ITEMS test_backends.py test_model_files.py test_backend_dispatch.py cli_git_tag.py)
string(REPLACE ".py" "" test_name "${test}")
add_test(NAME "frameyap.${test_name}" COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p "${test}")
set_tests_properties("frameyap.${test_name}" PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 60)
endforeach()
add_test(NAME frameyap.action_bindings COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_action_bindings.py)
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)
+74
View File
@@ -0,0 +1,74 @@
#include "check.hpp"
#ifdef FRAMEYAP_NATIVE
#include "instance_lock.hpp"
#include "overlay.hpp"
#include "text_input.hpp"
#include <openvr.h>
#include <chrono>
#include <iostream>
#include <thread>
namespace frameyap {
namespace {
const char* action_name(UiAction action) {
switch (action) {
case UiAction::BeginRecord: return "BeginRecord";
case UiAction::EndRecord: return "EndRecord";
case UiAction::Record: return "Record";
case UiAction::Cancel: return "Cancel";
case UiAction::Insert: return "Type";
case UiAction::Enter: return "Type + Enter";
case UiAction::QuickChat: return "Quick phrases";
case UiAction::Quit: return "Quit";
case UiAction::Toggle: return "Toggle";
}
return "Unknown";
}
} // namespace
int check_input(const std::string& socket) {
InstanceLock lock;
TextInput input(socket);
std::cout << "Gamescope IME v2 ready; no input delivered.\n";
return 0;
}
int check_overlay(const std::string& assets, const std::string& font,
std::optional<Mount> mount, bool controls) {
InstanceLock lock;
Overlay overlay(assets, font, mount, false);
const Panel panel = controls
? Panel{"Controls check - watch terminal for events", "No audio captured. No text or Enter delivered.",
"Click Record, Cancel or tabs. Actions are diagnostic only.", true, false}
: Panel{"FrameYap five-second visual check", "No audio captured. No text or Enter delivered.",
"Controls inactive during this check.", false, false};
overlay.draw(panel);
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';
}
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(controls ? 30 : 5);
std::string status, pointer_status;
bool quit_check = false;
while (!quit_check && std::chrono::steady_clock::now() < end) {
for (auto action : overlay.poll()) {
if (action == UiAction::Quit) { quit_check = true; break; }
if (controls) std::cout << action_name(action) << " received; diagnostic only." << std::endl;
}
if (controls) {
auto next = overlay.controls_status();
if (next != status) { status = next; std::cout << status << std::endl; }
auto pointer = overlay.pointer_status();
if (pointer != pointer_status) { pointer_status = pointer; std::cout << pointer_status << std::endl; }
}
// Keep the canvas fixed for action clicks; placement changes still redraw.
overlay.draw(panel);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (controls) std::cout << "Final " << overlay.pointer_status() << std::endl;
std::cout << "Overlay APIs accepted panel; physical visibility requires human confirmation.\n";
return 0;
}
} // namespace frameyap
#endif
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <string>
#include <optional>
#ifdef FRAMEYAP_NATIVE
#include "mount.hpp"
namespace frameyap {
int check_input(const std::string& socket);
int check_overlay(const std::string& assets, const std::string& font,
std::optional<Mount> mount, bool controls);
}
#endif
+127
View File
@@ -0,0 +1,127 @@
#include "cli.hpp"
#include <array>
#include <filesystem>
#include <string_view>
namespace frameyap {
namespace {
using Mode = CliMode;
constexpr unsigned bit(Mode mode) { return 1u << static_cast<unsigned>(mode); }
constexpr unsigned run = bit(Mode::Run), input = bit(Mode::CheckInput);
constexpr unsigned visual = bit(Mode::CheckOverlay) | bit(Mode::CheckControls);
constexpr unsigned models = bit(Mode::ListModels) | bit(Mode::CheckModel);
struct ModeSpec { std::string_view name; Mode mode; bool positional; };
constexpr std::array modes{
ModeSpec{"--help", Mode::Help, false}, ModeSpec{"--version", Mode::Version, false},
ModeSpec{"--run", Mode::Run, false}, ModeSpec{"--register", Mode::Register, true},
ModeSpec{"--unregister", Mode::Unregister, true},
ModeSpec{"--check-input", Mode::CheckInput, false},
ModeSpec{"--check-overlay", Mode::CheckOverlay, false},
ModeSpec{"--check-controls", Mode::CheckControls, false},
ModeSpec{"--list-models", Mode::ListModels, false},
ModeSpec{"--check-model", Mode::CheckModel, true},
};
struct OptionSpec { std::string_view name; unsigned allowed; std::string CliOptions::* field; bool flag; };
constexpr std::array options{
OptionSpec{"--assets", run | visual, &CliOptions::assets, false},
OptionSpec{"--font", run | visual, &CliOptions::font, false},
OptionSpec{"--socket", run | input | bit(Mode::CheckControls), &CliOptions::socket, false},
OptionSpec{"--mount", run | visual, &CliOptions::mount, false},
OptionSpec{"--worker", run, &CliOptions::worker, false},
OptionSpec{"--python", run, &CliOptions::python, false},
OptionSpec{"--model", run, &CliOptions::model, false},
OptionSpec{"--threads", run, &CliOptions::threads, false},
OptionSpec{"--model-dir", models, &CliOptions::model_dir, false},
OptionSpec{"--backend", run, &CliOptions::backend, false},
OptionSpec{"--model-store", run, &CliOptions::model_store, false},
OptionSpec{"--manifest-dir", models | run, &CliOptions::manifest_dir, false},
OptionSpec{"--head", run | visual, nullptr, true},
OptionSpec{"--autostart", bit(Mode::Register), nullptr, true},
OptionSpec{"--json", models, nullptr, true},
};
// Matches python/frameyap/model_files.py's manifest ID grammar. Reject invalid
// IDs before runtime setup; no basename or option-looking ID can be selected.
bool valid_backend(std::string_view id) {
if (id.empty() || id.size() > 48 || id.front() < 'a' || id.front() > 'z') return false;
for (char c : id) if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-')) return false;
return true;
}
bool safe_absolute_path(const std::string& value) {
const std::filesystem::path path(value);
if (!path.is_absolute()) return false;
for (const auto& part : path) if (part == "." || part == "..") return false;
for (unsigned char c : value) if (c < 32 || c == 127) return false;
return true;
}
} // namespace
std::filesystem::path asset_root(const std::string& assets) {
if (assets.empty()) return {};
auto path = std::filesystem::absolute(std::filesystem::path(assets)).lexically_normal();
// path("assets/") has an empty final component on some standard libraries.
while (path.filename().empty() && path.has_parent_path() && path != path.root_path())
path = path.parent_path();
return path.parent_path();
}
bool is_managed_worker(const std::string& assets, const std::string& worker) {
if (assets.empty() || worker.empty()) return false;
const auto expected = (asset_root(assets) / "python/frameyap/worker.py").lexically_normal();
return std::filesystem::absolute(std::filesystem::path(worker)).lexically_normal() == expected;
}
CliOptions parse_cli(int argc, const char* const argv[]) {
CliOptions result;
if (argc <= 1) return result;
const std::string_view first(argv[1]);
const ModeSpec* spec = nullptr;
for (const auto& candidate : modes) if (first == candidate.name) { spec = &candidate; break; }
if (!spec) throw CliError("Unsupported arguments. Use --help.");
result.mode = spec->mode;
int i = 2;
if (spec->positional) {
if (i == argc || !argv[i][0] || argv[i][0] == '-')
throw CliError("Missing value for " + std::string(first));
if (result.mode == Mode::CheckModel) result.model_id = argv[i++];
else result.manifest = argv[i++];
}
for (; i < argc; ++i) {
std::string_view arg(argv[i]);
const OptionSpec* option = nullptr;
for (const auto& candidate : options) if (arg == candidate.name) { option = &candidate; break; }
if (!option || !(option->allowed & bit(result.mode)))
throw CliError("Unsupported arguments: " + std::string(arg));
if (option->flag) {
bool* target = arg == "--head" ? &result.head :
arg == "--json" ? &result.json : &result.autostart;
if (*target) throw CliError("Duplicate option: " + std::string(arg));
*target = true;
} else {
if (i + 1 == argc || !argv[i + 1][0] || argv[i + 1][0] == '-')
throw CliError("Missing value for " + std::string(arg));
auto& value = result.*(option->field);
if (!value.empty()) throw CliError("Duplicate option: " + std::string(arg));
value = argv[++i];
}
}
if (result.head && !result.mount.empty()) throw CliError("--head and --mount cannot be combined");
if (result.mode == Mode::Run && result.threads != "" && result.threads != "2" && result.threads != "4")
throw CliError("--threads must be 2 or 4");
if (result.mode == Mode::CheckModel && result.model_dir.empty())
throw CliError("--check-model requires --model-dir");
if (result.mode == Mode::CheckModel && !valid_backend(result.model_id))
throw CliError("--check-model requires a manifest ID ([a-z][a-z0-9_-]{0,47})");
if (result.mode == Mode::Run) {
if (!result.backend.empty() && !valid_backend(result.backend))
throw CliError("--backend must be a manifest ID ([a-z][a-z0-9_-]{0,47})");
if (!result.manifest_dir.empty() && !safe_absolute_path(result.manifest_dir))
throw CliError("--manifest-dir must be an absolute local directory without dot segments or controls");
if (!result.model_store.empty() && !safe_absolute_path(result.model_store))
throw CliError("--model-store must be an absolute local directory without dot segments or controls");
// Only the worker at the app root inferred from --assets belongs to
// the managed dispatcher. A namesake script elsewhere is custom.
if (!result.worker.empty() && !is_managed_worker(result.assets, result.worker)) {
if (!result.backend.empty()) throw CliError("--backend cannot be combined with a custom --worker");
if (result.model.empty()) throw CliError("--model required with a custom --worker");
}
}
return result;
}
} // namespace frameyap
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <filesystem>
#include <stdexcept>
#include <string>
namespace frameyap {
enum class CliMode { Help, Version, Run, Register, Unregister, CheckInput, CheckOverlay,
CheckControls, ListModels, CheckModel };
struct CliOptions {
CliMode mode = CliMode::Help;
std::string manifest, assets, font, mount, socket, worker, python, model, threads;
std::string model_id, model_dir, manifest_dir, model_store, backend;
bool head = false, autostart = false, json = false;
};
struct CliError : std::runtime_error { using std::runtime_error::runtime_error; };
// Lexical installation identity shared by CLI and runtime. No filesystem access.
std::filesystem::path asset_root(const std::string& assets);
bool is_managed_worker(const std::string& assets, const std::string& worker);
// Parse syntax only: never initializes hardware, accesses model files or opens sockets.
CliOptions parse_cli(int argc, const char* const argv[]);
} // namespace frameyap
+373
View File
@@ -0,0 +1,373 @@
#include "controller.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <utility>
namespace frameyap {
namespace {
std::string state_label(State state) {
switch (state) {
case State::Warming: return "Warming - on-device model";
case State::Ready: return "Ready - hold X or click Record";
case State::Recording: return "RECORDING";
case State::Transcribing: return "Transcribing - on device";
case State::Review: return "Review - Type approves CURRENT focus";
case State::Queued: return "Input queued - not a delivery receipt";
case State::Error: return "Unavailable - Record to retry";
}
return {};
}
} // namespace
Controller::Controller(ControllerAudio& audio, ControllerWorker& worker,
DeliveryFactory delivery, FocusFactory focus,
std::vector<std::string> quick_inputs, bool auto_insert,
bool advanced_debug, bool close_mic_when_idle, PacedDelivery* paced)
: audio_(audio), worker_(worker), delivery_(std::move(delivery)), paced_(paced),
focus_factory_(std::move(focus)), quick_inputs_(std::move(quick_inputs)),
auto_insert_(auto_insert), advanced_debug_(advanced_debug),
close_mic_when_idle_(close_mic_when_idle) {}
void Controller::warm() {
if (paced_) paced_->cancel();
status_note_.clear(); quick_open_ = false;
armed_focus_.reset(); audio_.close(); worker_.stop(); session_ = Session{};
worker_.start(advanced_debug_);
detail_ = "Loading local model; microphone closed. Record again when Ready.";
}
void Controller::initialize() {
try { warm(); }
catch (const std::exception& e) { session_.fail(); detail_ = e.what(); }
}
void Controller::backend_changed(bool verified, std::string detail) {
const bool partial = paced_ && paced_->began();
if (paced_) paced_->cancel();
armed_focus_.reset(); audio_.close(); worker_.stop(); session_ = Session{};
quick_open_ = false; status_note_.clear();
backend_available_ = verified;
if (!verified) {
session_.fail(); detail_ = detail.empty() ? "Model not installed and verified. Choose Install in Settings." : std::move(detail);
} else {
try { warm(); } catch (const std::exception& e) { session_.fail(); detail_ = e.what(); }
}
if (partial) {
status_note_ = "Input stopped - partial delivery uncertain";
detail_ += " Earlier input may have queued; remainder and Enter dropped.";
}
}
void Controller::shutdown() {
if (paced_) paced_->cancel();
armed_focus_.reset(); audio_.close(); session_.cancel(); worker_.stop();
}
void Controller::request_failed(const std::string& error) {
armed_focus_.reset(); session_.fail();
// Neither raw worker output nor malformed transcript is printed here.
detail_ = error + "; model ready. Record to retry.";
std::cerr << "FrameYap worker: " << error << '\n';
}
void Controller::delivery_detail(DeliveryResult result, bool submit) {
switch (result) {
case DeliveryResult::Ignored: break;
case DeliveryResult::TextQueued:
detail_ = "Text queued to current focus (trailing space if it fits). Enter remains explicit.";
status_note_ = "Text queued - Enter not sent"; break;
case DeliveryResult::EnterQueued:
detail_ = submit ? "Explicit Enter queued to current focus; not a delivery receipt."
: "Input queued to current focus; not a delivery receipt.";
status_note_ = "Enter queued - check destination"; break;
case DeliveryResult::TextUncertain:
detail_ = "Text delivery uncertain; Enter not sent. Not retried; check destination.";
status_note_ = "Text uncertain - Enter not sent"; break;
case DeliveryResult::EnterUncertain:
detail_ = "Enter delivery uncertain; not retried. Check destination.";
status_note_ = "Enter uncertain - check destination"; break;
case DeliveryResult::TextQueuedEnterUnavailable:
detail_ = "Text queued; Enter unavailable and not sent. Check destination.";
status_note_ = "Text queued - Enter unavailable"; break;
}
}
std::shared_ptr<ControllerFocus> Controller::manual_focus() {
auto focus = focus_factory_();
if (!focus || !focus->arm()) throw std::runtime_error("Current target not verified; review kept");
return std::shared_ptr<ControllerFocus>(std::move(focus));
}
void Controller::queue_paced(std::string text, bool enter, bool review,
std::shared_ptr<ControllerFocus> focus, bool automatic) {
auto guard = [focus = std::move(focus)] { return focus->valid(); };
paced_->start(std::move(text), enter, std::move(guard), [this, review] {
if (review) {
auto consumed = session_.take_insert();
if (!consumed) throw std::runtime_error("Review was revoked");
std::fill(consumed->begin(), consumed->end(), '\0');
}
});
paced_auto_ = automatic;
paced_submit_ = enter;
detail_ = "Input paced to captured focus; check destination. Cancel stops remaining input.";
status_note_ = "Input pacing - not a delivery receipt";
quick_open_ = false;
}
void Controller::stop_record() {
if (session_.state() != State::Recording) return;
auto pcm = audio_.finish();
try {
if (session_.finish(pcm.size())) {
worker_.submit(session_.id(), pcm);
detail_ = armed_focus_ ? "Release complete; checking stable focus before auto insert." :
"Release complete. Review before typing.";
} else { armed_focus_.reset(); detail_ = "Short tap discarded (minimum 200ms)."; }
} catch (...) {
std::fill(pcm.begin(), pcm.end(), 0.0f);
throw;
}
std::fill(pcm.begin(), pcm.end(), 0.0f);
if (close_mic_when_idle_) audio_.close();
}
void Controller::start_record() {
if ((paced_ && paced_->active()) || session_.state() == State::Review ||
session_.state() == State::Transcribing || session_.state() == State::Warming) return;
if (!backend_available_) return;
if (!worker_.ready()) { warm(); return; }
if (!audio_.open()) audio_.prepare(); // only on PTT when close-when-idle is enabled
if (session_.state() == State::Error) session_.cancel();
if (!session_.record()) return;
status_note_.clear(); armed_focus_.reset();
if (auto_insert_) {
auto candidate = focus_factory_();
if (candidate && candidate->arm()) armed_focus_ = std::move(candidate);
}
audio_.start();
detail_ = auto_insert_ && !armed_focus_ ? "Focus unverified; recording will require manual Type." :
"Release to finish. Cancel discards. Maximum 20 seconds.";
}
void Controller::tick() {
if (armed_focus_) {
bool valid = false;
try { valid = armed_focus_->valid(); } catch (const std::exception&) {}
if (!valid) {
armed_focus_.reset();
detail_ = "Focus changed or became uncertain; transcript will require manual Type.";
}
}
try {
if (auto reply = worker_.poll()) {
if (reply->id == session_.id() && session_.state() == State::Transcribing) {
if (!reply->error.empty()) {
// Correlated E replies are request-level failures, not model failures.
request_failed("transcription failed");
} else {
try { session_.reply(reply->id, reply->text); }
catch (const std::exception&) {
// Malformed controls/UTF-8 are also request-level failures.
// The worker has already consumed this reply and stays loaded.
request_failed("transcription failed");
}
if (session_.state() != State::Error) {
detail_ = session_.text().empty() ? "No speech recognized; try again." :
"Focus your destination, then Type. Cancel discards.";
if (session_.state() == State::Review && auto_insert_ && armed_focus_) {
try {
if (paced_) {
auto text = session_.text();
if (text.back() != ' ' && text.size() < 4096) text += ' ';
queue_paced(std::move(text), false, true,
std::shared_ptr<ControllerFocus>(std::move(armed_focus_)), true);
} else {
// Fake synchronous path retains its existing single-send contract.
const DeliveryFactory guarded = [&]() -> std::unique_ptr<DeliveryLease> {
auto lease = delivery_();
if (!armed_focus_ || !armed_focus_->valid())
throw std::runtime_error("Focus changed during input authorization");
return lease;
};
const auto outcome = deliver_insert(session_, guarded);
delivery_detail(outcome, false);
if (outcome == DeliveryResult::TextQueued)
detail_ = "Auto insert queued text to verified focus (space if it fits); never Enter. Not a delivery receipt.";
}
} catch (const std::exception&) {
detail_ = "Auto insert blocked; text kept for manual review. Check focus and Type.";
}
}
armed_focus_.reset();
}
}
}
}
if (worker_.ready()) session_.ready();
} catch (const std::exception& e) {
const bool partial = paced_ && paced_->began();
if (paced_) paced_->cancel();
armed_focus_.reset(); audio_.close(); worker_.stop();
if (session_.state() != State::Review && session_.state() != State::Queued) session_.fail();
detail_ = e.what(); // Preserve an already-correlated preview on worker failure.
if (partial) {
status_note_ = "Input stopped - partial delivery uncertain";
detail_ += "; earlier input may have queued; remainder and Enter dropped.";
}
}
if (paced_) {
if (auto outcome = paced_->tick()) {
if (!outcome->began) {
detail_ = outcome->focus_lost ? "Focus changed before input; review kept for a new manual Type." :
"Input unavailable before delivery; review kept if pending.";
status_note_ = "Input blocked - check destination";
} else {
delivery_detail(outcome->result, paced_submit_);
if (outcome->focus_lost)
detail_ = "Focus changed during pacing; remaining input and Enter dropped. Earlier input may have queued.";
else if (paced_auto_ && outcome->result == DeliveryResult::TextQueued)
detail_ = "Auto insert queued text to continuously verified focus; never Enter. Not a delivery receipt.";
}
paced_auto_ = paced_submit_ = false;
}
}
try {
// Default: keep the device open and drain idle samples. Optional privacy
// policy: open at PTT, close immediately when a clip is complete.
if (!close_mic_when_idle_ && session_.state() == State::Ready &&
!audio_.open() && worker_.ready()) audio_.prepare();
if (audio_.open() && audio_.poll()) stop_record();
} catch (const std::exception& e) {
const bool partial = paced_ && paced_->began();
if (paced_) paced_->cancel();
audio_.close(); session_.fail(); detail_ = e.what(); armed_focus_.reset();
if (partial) {
status_note_ = "Input stopped - partial delivery uncertain";
detail_ += "; earlier input may have queued; remainder and Enter dropped.";
}
}
if (quick_open_ && (session_.state() == State::Recording || session_.state() == State::Transcribing ||
session_.state() == State::Warming || session_.state() == State::Error))
quick_open_ = false;
}
Panel Controller::panel() const {
auto status = session_.state() == State::Error && worker_.ready()
? "Retry available - local model still loaded" : state_label(session_.state());
Panel result{quick_open_ ? "Quick phrases" : status_note_.empty() ? status : status_note_,
session_.text(), detail_,
!(paced_ && paced_->active()) && session_.state() != State::Error && session_.state() != State::Warming && session_.state() != State::Transcribing,
session_.state() == State::Recording,
!(paced_ && paced_->active()) && backend_available_ && session_.state() != State::Warming && session_.state() != State::Transcribing && session_.state() != State::Review};
result.quick_open = quick_open_;
result.quick_selected = quick_selected_;
result.quick_inputs = quick_inputs_;
if (result.recording) result.status += " - " + std::to_string(audio_.seconds()) + " / 20s";
return result;
}
void Controller::settings(bool auto_insert, bool advanced_debug, bool close_mic_when_idle) {
const bool delivery_setting_changed = auto_insert_ != auto_insert || advanced_debug_ != advanced_debug;
const bool was_partial = delivery_setting_changed && paced_ && paced_->began();
if (auto_insert_ != auto_insert) {
const bool partial = paced_ && paced_->began();
if (paced_) paced_->cancel();
auto_insert_ = auto_insert;
armed_focus_.reset(); // never retroactively authorize a clip
detail_ = auto_insert ? "Auto insert enabled for future clips only when X focus stays verified. Never Enter." :
"Auto insert disabled; review before Type.";
if (partial) {
status_note_ = "Input stopped - partial delivery uncertain";
detail_ += " Earlier input may have queued; remainder and Enter dropped.";
}
}
if (close_mic_when_idle_ != close_mic_when_idle) {
close_mic_when_idle_ = close_mic_when_idle;
if (close_mic_when_idle_ && session_.state() != State::Recording) audio_.close();
detail_ = close_mic_when_idle_ ? "Mic closes between clips. Opening on PTT may delay or clip speech." :
"Mic stays open while idle; samples are discarded. Other apps may still hear you.";
}
if (advanced_debug_ != advanced_debug) {
const bool partial = paced_ && paced_->began();
advanced_debug_ = advanced_debug;
if (backend_available_) {
try { warm(); }
catch (const std::exception& e) { session_.fail(); detail_ = e.what(); }
} else { if (paced_) paced_->cancel(); armed_focus_.reset(); audio_.close(); worker_.stop(); session_.fail(); }
if (partial) {
status_note_ = "Input stopped - partial delivery uncertain";
detail_ += " Earlier input may have queued; remainder and Enter dropped.";
}
}
if (was_partial) {
status_note_ = "Input stopped - partial delivery uncertain";
if (detail_.find("Earlier input may have queued") == std::string::npos)
detail_ += " Earlier input may have queued; remainder and Enter dropped.";
}
}
void Controller::action(UiAction action) {
if (action == UiAction::Quit) { if (paced_) paced_->cancel(); quit_ = true; return; }
if (paced_ && paced_->active() && action != UiAction::Cancel) return;
try {
switch (action) {
case UiAction::Toggle:
case UiAction::Record:
if (quick_open_) break;
if (session_.state() == State::Recording) stop_record(); else start_record();
break;
case UiAction::BeginRecord: if (!quick_open_) start_record(); break;
case UiAction::EndRecord: stop_record(); break;
case UiAction::Cancel: {
if (quick_open_) { quick_open_ = false; break; }
status_note_.clear();
const bool partial = paced_ && paced_->began();
if (paced_) paced_->cancel();
auto state = session_.state();
armed_focus_.reset(); audio_.cancel(); session_.cancel();
detail_ = close_mic_when_idle_ ? "Discarded. Microphone closed between clips." :
"Discarded. Idle microphone samples are discarded.";
if (close_mic_when_idle_) audio_.close();
if (state == State::Warming || state == State::Transcribing) {
audio_.close(); worker_.stop(); session_.fail(); detail_ = "Cancelled. Record to reload local worker.";
} else if (state == State::Error && !worker_.ready()) {
audio_.close(); session_.fail(); detail_ = "Worker unavailable. Record to reload local worker.";
}
if (partial) {
status_note_ = "Input stopped - partial delivery uncertain";
detail_ = "Earlier input may have queued; remainder and Enter dropped. Check destination.";
}
break;
}
case UiAction::Insert:
if (!quick_open_ && session_.state() == State::Review) {
if (paced_) {
auto text = session_.text();
if (text.back() != ' ' && text.size() < 4096) text += ' ';
queue_paced(std::move(text), false, true, manual_focus());
} else delivery_detail(deliver_insert(session_, delivery_), false);
}
break;
case UiAction::Enter:
if (quick_open_) {
quick_open_ = false; // explicit quick selection, even on unavailable input
if (paced_) {
const auto& quick = quick_inputs_.at(quick_selected_);
if (quick.empty() || quick.size() > 64 || literal_text(quick) != quick)
throw std::runtime_error("Invalid quick input");
queue_paced(quick, true, false, manual_focus());
} else delivery_detail(deliver_quick(quick_inputs_.at(quick_selected_), delivery_), true);
} else if (paced_) {
if (session_.state() == State::Review) {
auto text = session_.text();
if (text.back() != ' ' && text.size() < 4096) text += ' ';
queue_paced(std::move(text), true, true, manual_focus());
} else if (session_.state() == State::Ready || session_.state() == State::Queued)
queue_paced({}, true, false, manual_focus());
} else delivery_detail(deliver_enter(session_, delivery_), true);
break;
case UiAction::QuickChat:
if (panel().enabled && session_.state() != State::Recording && !quick_inputs_.empty()) {
if (quick_open_) quick_selected_ = (quick_selected_ + 1) % quick_inputs_.size();
else { quick_selected_ = 0; quick_open_ = true; }
}
break;
case UiAction::Quit: break;
}
} catch (const std::exception& e) {
if (session_.state() == State::Recording || session_.state() == State::Transcribing) {
audio_.close(); session_.fail(); armed_focus_.reset(); status_note_.clear();
} else status_note_ = "Input unavailable - check destination";
detail_ = e.what();
}
}
} // namespace frameyap
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include "core.hpp"
#include "paced_delivery.hpp"
#include "overlay.hpp"
#include "worker.hpp"
#include <functional>
#include <memory>
#include <vector>
namespace frameyap {
// Hardware-free ownership boundaries. Implementations may open devices only in
// these explicitly requested methods; constructing a Controller has no side effects.
class ControllerAudio {
public:
virtual ~ControllerAudio() = default;
virtual bool open() const = 0;
virtual void prepare() = 0;
virtual void start() = 0;
virtual bool poll() = 0;
virtual std::vector<float> finish() = 0;
virtual void cancel() = 0;
virtual void close() = 0;
virtual int seconds() const = 0;
};
class ControllerWorker {
public:
virtual ~ControllerWorker() = default;
virtual void start(bool advanced_debug) = 0;
virtual bool ready() const = 0;
virtual void submit(uint64_t id, const std::vector<float>& pcm) = 0;
virtual std::optional<WorkerReply> poll() = 0;
virtual void stop() = 0;
};
class ControllerFocus {
public:
virtual ~ControllerFocus() = default;
virtual bool arm() = 0;
virtual bool valid() = 0;
};
using FocusFactory = std::function<std::unique_ptr<ControllerFocus>()>;
class Controller {
public:
Controller(ControllerAudio& audio, ControllerWorker& worker,
DeliveryFactory delivery, FocusFactory focus,
std::vector<std::string> quick_inputs, bool auto_insert = false,
bool advanced_debug = false, bool close_mic_when_idle = false,
PacedDelivery* paced = nullptr);
// Called explicitly by runtime, after setting up the UI. A failure is shown
// in Panel and can be retried with Record.
void initialize();
// Invalidate clip, preview and focus before switching manifest/worker.
// A missing/unverified backend cannot be retried by the Record button.
void backend_changed(bool verified, std::string detail = {});
void tick(); // nonblocking poll of focus, worker, microphone; no UI or hardware setup
Panel panel() const;
void settings(bool auto_insert, bool advanced_debug, bool close_mic_when_idle);
void action(UiAction action);
bool quitting() const { return quit_; }
void shutdown(); // releases only injected resources; safe to call repeatedly
State state() const { return session_.state(); }
private:
void warm();
void start_record();
void stop_record();
void request_failed(const std::string& error);
void delivery_detail(DeliveryResult result, bool submit);
void queue_paced(std::string text, bool enter, bool review,
std::shared_ptr<ControllerFocus> focus, bool automatic = false);
std::shared_ptr<ControllerFocus> manual_focus();
ControllerAudio& audio_;
ControllerWorker& worker_;
DeliveryFactory delivery_;
PacedDelivery* paced_ = nullptr; // optional: native tick-driven delivery
bool paced_auto_ = false, paced_submit_ = false;
FocusFactory focus_factory_;
std::vector<std::string> quick_inputs_;
Session session_;
std::unique_ptr<ControllerFocus> armed_focus_;
std::string detail_, status_note_;
bool quit_ = false, quick_open_ = false;
size_t quick_selected_ = 0;
bool auto_insert_, advanced_debug_, close_mic_when_idle_;
bool backend_available_ = true;
};
} // namespace frameyap
+4 -7
View File
@@ -64,14 +64,11 @@ std::unique_ptr<DeliveryLease> lease(const DeliveryFactory& acquire) {
return result;
}
DeliveryResult send_review(Session& session, const DeliveryFactory& acquire, bool submit) {
// literal_text already validated the transcript at reply time. Do not
// truncate at the boundary: the suffix is also subject to the 4096-byte
// Gamescope text limit, including any existing trailing whitespace.
const bool needs_space = session.text().back() != ' ';
if (needs_space && session.text().size() >= 4096)
throw std::runtime_error("Cannot append space: transcript fills the 4096-byte input limit; discard and dictate a shorter clip");
// literal_text already validated the transcript at reply time. A suffix
// cannot fit beside a full 4096-byte transcript: prefer the entire literal
// with no trailing space over rejecting it or dropping transcript bytes.
auto spaced = session.text();
if (needs_space) spaced += ' ';
if (spaced.back() != ' ' && spaced.size() < 4096) spaced += ' ';
auto input = lease(acquire); // Failed acquisition keeps the review available.
session.take_insert(); // Consume before any potentially ambiguous send.
try { input->text(spaced); }
+2 -2
View File
@@ -42,8 +42,8 @@ enum class DeliveryResult {
Ignored, TextQueued, EnterQueued, TextUncertain, EnterUncertain, TextQueuedEnterUnavailable
};
// Lease acquisition before consumption can throw while leaving review intact.
// The suffix must fit inside the same 4096-byte validated text bound; overflow
// throws before consumption rather than silently dropping transcript bytes.
// A full 4096-byte transcript is sent intact without an additional space;
// never truncate transcript bytes merely to make room for the suffix.
DeliveryResult deliver_insert(Session& session, const DeliveryFactory& acquire);
// Review: insert text plus a trailing space, then explicitly send Enter only
// after successful text delivery. Ready/Queued: Enter alone.
+82 -122
View File
@@ -1,26 +1,32 @@
#include <iostream>
#include <string_view>
#include <string>
#include "cli.hpp"
#include "check.hpp"
#include "model_status.hpp"
#include <cstdlib>
#include <iostream>
#include <string>
#ifdef FRAMEYAP_NATIVE
#include "runtime.hpp"
#include "overlay.hpp"
#include "text_input.hpp"
#include "instance_lock.hpp"
#include <openvr.h>
#include <chrono>
#include <thread>
#include "overlay.hpp"
#endif
namespace {
void help() {
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] [--mount MODE]\n"
std::cout << "FrameYap — standalone Steam Frame voice typing\n"
"Usage: frameyap [--help | --version | --list-models | --check-model ID]\n"
" --list-models [--model-dir STORE] [--manifest-dir DIR] [--json]\n"
" --check-model ID --model-dir DIR [--manifest-dir DIR] [--json]\n"
" --run --assets DIR [--font FILE] [--backend ID] [--manifest-dir DIR]\n"
" [--model-store DIR] [--model DIR] [--python FILE] [--threads 2|4]\n"
" [--worker FILE] [--socket gamescope-0] [--mount MODE]\n"
" --register MANIFEST [--autostart] | --unregister MANIFEST\n"
" --check-input [--socket gamescope-0] (discovery only, no typing)\n"
" --check-overlay --assets DIR [--font FILE] [--mount MODE] (5s, no mic/input)\n"
" --check-controls --assets DIR [--font FILE] [--mount MODE] (30s, gestures only)\n\n"
"Model status is offline: --check-model hashes pinned local files and never loads inference.\n"
"Run without --worker/--model to use the manifest-managed Models UI; install requires consent.\n"
"Custom --worker scripts still require --model; --manifest-dir and --model-store\n"
"run overrides must be absolute local paths. No model or runtime downloads on startup.\n"
"Mount: world (first-launch default), left-wrist, right-wrist, head.\n"
"Settings save the mount, opt-in Lasers anytime mode and Auto insert (off by default).\n"
"--mount overrides placement for this launch. --head is an alias.\n"
@@ -31,123 +37,77 @@ void help() {
"SteamVR Developer setting Enable global input from overlays.\n"
"Right X: hold to speak, release to review (default Frame binding).\n"
"Grip gestures are remappable but may be unavailable in the dashboard.\n"
"Right B: cancel; A: insert + space; Y: quick chat picker.\n"
"Left grip: double-tap to Submit (Enter alone when no review).\n"
"Right B: cancel; A: Type (text + space); Y: Quick phrases picker.\n"
"Left grip: double-tap to Type + Enter (Enter alone when no review).\n"
"Review by default. Auto insert requires uninterrupted verified Xwayland focus.\n"
"Insert approves current focus; Enter is never automatic.\n"
"Type approves current focus; Enter is never automatic.\n"
"No device access unless an explicit runtime/check/registration mode is used.\n";
#ifndef FRAMEYAP_NATIVE
std::cout << "This offline build has no hardware backends; enable FRAMEYAP_NATIVE to run.\n";
#endif
}
}
int main(int argc, char** argv) {
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 << "frameyap " << FRAMEYAP_VERSION << "\n"; return 0;
}
#ifdef FRAMEYAP_NATIVE
int native_mode(const frameyap::CliOptions& cli) {
using frameyap::CliMode;
if (cli.mode == CliMode::Register || cli.mode == CliMode::Unregister) {
frameyap::InstanceLock lock;
return frameyap::registration(cli.manifest, cli.mode == CliMode::Unregister, cli.autostart);
}
frameyap::Options options;
const char* socket = std::getenv("GAMESCOPE_WAYLAND_DISPLAY");
options.socket = !cli.socket.empty() ? cli.socket : socket && *socket ? socket : "gamescope-0";
options.assets = cli.assets;
options.font = cli.font;
if (!cli.mount.empty()) {
options.mount = frameyap::parse_mount(cli.mount);
if (!options.mount) throw std::runtime_error("Mount must be world, left-wrist, right-wrist or head");
}
if (cli.head) options.mount = frameyap::Mount::Head;
if (cli.mode == CliMode::CheckInput) return frameyap::check_input(options.socket);
if (options.assets.empty()) throw frameyap::CliError("--assets required");
if (cli.mode == CliMode::CheckOverlay || cli.mode == CliMode::CheckControls)
return frameyap::check_overlay(options.assets, options.font, options.mount,
cli.mode == CliMode::CheckControls);
options.worker = cli.worker;
options.model = cli.model;
options.backend = cli.backend;
options.manifest_dir = cli.manifest_dir;
options.model_store = cli.model_store;
if (!cli.python.empty()) options.python = cli.python;
if (!cli.threads.empty()) options.threads = std::stoi(cli.threads);
return frameyap::run(options);
}
#endif
} // namespace
int main(int argc, char** argv) {
frameyap::CliOptions cli;
try {
cli = frameyap::parse_cli(argc, const_cast<const char* const*>(argv));
} catch (const frameyap::CliError& error) {
std::cerr << "FrameYap: " << error.what() << '\n';
return 2;
}
if (cli.mode == frameyap::CliMode::Help) { help(); return 0; }
if (cli.mode == frameyap::CliMode::Version) {
std::cout << "frameyap " << FRAMEYAP_VERSION << '\n';
if (std::string(FRAMEYAP_GIT_INFO).size()) std::cout << FRAMEYAP_GIT_INFO << '\n';
return 0;
}
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.mount = frameyap::Mount::Head; 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 == "--mount" && mode != "--check-input") {
options.mount = frameyap::parse_mount(value);
if (!options.mount) throw std::runtime_error("Mount must be world, left-wrist, right-wrist or head");
}
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()) throw std::runtime_error("--assets required");
if (mode == "--check-overlay" || mode == "--check-controls") {
frameyap::InstanceLock lock;
frameyap::Overlay overlay(options.assets, options.font, options.mount, false);
const frameyap::Panel check_panel = mode == "--check-controls"
? frameyap::Panel{"Controls check - watch terminal for events", "No audio captured. No text or Enter delivered.",
"Click Record, Cancel or tabs. Actions are diagnostic only.", true, false}
: frameyap::Panel{"FrameYap five-second visual check", "No audio captured. No text or Enter delivered.",
"Controls inactive during this check.", false, false};
overlay.draw(check_panel);
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;
std::string pointer_status;
bool quit_check = false;
auto action_name = [](frameyap::UiAction action) {
switch (action) {
case frameyap::UiAction::BeginRecord: return "BeginRecord";
case frameyap::UiAction::EndRecord: return "EndRecord";
case frameyap::UiAction::Record: return "Record";
case frameyap::UiAction::Cancel: return "Cancel";
case frameyap::UiAction::Insert: return "Insert";
case frameyap::UiAction::Enter: return "Submit";
case frameyap::UiAction::QuickChat: return "QuickChat";
case frameyap::UiAction::Quit: return "Quit";
case frameyap::UiAction::Toggle: return "Toggle";
}
return "Unknown";
};
while (!quit_check && std::chrono::steady_clock::now() < end) {
for (auto action : overlay.poll()) {
if (action == frameyap::UiAction::Quit) { quit_check = true; break; }
std::cout << action_name(action) << " received; diagnostic only." << std::endl;
}
auto next = overlay.controls_status();
if (next != status) { status = next; std::cout << status << std::endl; }
auto pointer = overlay.pointer_status();
if (pointer != pointer_status) { pointer_status = pointer; std::cout << pointer_status << std::endl; }
// Keep the canvas fixed for action clicks: otherwise the
// changing counters would upload a texture on every down/up.
// Tab/placement changes still redraw the correct controls.
overlay.draw(check_panel);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
std::cout << "Final " << overlay.pointer_status() << std::endl;
} else {
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (std::chrono::steady_clock::now() < end) {
for (auto action : overlay.poll()) if (action == frameyap::UiAction::Quit) return 0;
overlay.draw(check_panel);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
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; }
if (cli.mode == frameyap::CliMode::ListModels || cli.mode == frameyap::CliMode::CheckModel)
return frameyap::model_status(cli, argv[0]);
#ifdef FRAMEYAP_NATIVE
return native_mode(cli);
#else
std::cerr << "Unsupported arguments or runtime disabled in this build. Use --help.\n";
return 2;
#endif
std::cerr << "Unsupported arguments or runtime disabled in this build. Use --help.\n";
return 2;
} catch (const frameyap::CliError& error) {
std::cerr << "FrameYap: " << error.what() << '\n';
return 2;
} catch (const std::exception& error) {
std::cerr << "FrameYap: " << error.what() << '\n';
return 1;
}
}
+60
View File
@@ -0,0 +1,60 @@
#include "model_status.hpp"
#include <filesystem>
#include <stdexcept>
#include <string>
#include <vector>
#if defined(__unix__) || defined(__APPLE__)
#include <cerrno>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace frameyap {
int model_status(const CliOptions& options, const char* executable) {
#if defined(__unix__) || defined(__APPLE__)
namespace fs = std::filesystem;
// Installed layouts put scripts/, python/ and assets/ beside bin/.
// Resolve the actual binary rather than cwd or an untrusted PATH script.
fs::path binary;
#if defined(__linux__)
std::error_code error;
binary = fs::read_symlink("/proc/self/exe", error);
#endif
if (binary.empty()) binary = fs::absolute(executable);
fs::path script = binary.parent_path().parent_path() / "scripts/model-status.py";
// Out-of-tree builds can be nested arbitrarily; only the actual configured
// build executable may fall back to the source script. Installed binaries
// must carry their own scripts, not silently use a developer checkout.
if (!fs::is_regular_file(script) && binary.parent_path() == fs::path(FRAMEYAP_BUILD_ROOT))
script = fs::path(FRAMEYAP_SOURCE_ROOT) / "scripts/model-status.py";
if (!fs::is_regular_file(script))
throw std::runtime_error("model-status.py missing beside application layout: " + script.string());
std::vector<std::string> args{"python3", script.string()};
if (options.mode == CliMode::ListModels) args.emplace_back("--list-models");
else { args.emplace_back("--check-model"); args.push_back(options.model_id); }
if (!options.model_dir.empty()) { args.emplace_back("--model-dir"); args.push_back(options.model_dir); }
if (!options.manifest_dir.empty()) { args.emplace_back("--manifest-dir"); args.push_back(options.manifest_dir); }
if (options.json) args.emplace_back("--json");
std::vector<char*> argv;
for (auto& arg : args) argv.push_back(arg.data());
argv.push_back(nullptr);
const pid_t child = fork();
if (child < 0) throw std::runtime_error("could not start offline model verifier");
if (child == 0) {
execvp(argv[0], argv.data());
constexpr char missing[] = "FrameYap: python3 required for offline model status\n";
(void)write(STDERR_FILENO, missing, sizeof(missing) - 1);
_exit(127);
}
int status = 0;
while (waitpid(child, &status, 0) < 0) {
if (errno != EINTR) throw std::runtime_error("could not wait for model verifier");
}
if (WIFEXITED(status)) return WEXITSTATUS(status);
return 1;
#else
(void)options; (void)executable;
throw std::runtime_error("model status requires a POSIX host and Python 3.10+");
#endif
}
} // namespace frameyap
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include "cli.hpp"
namespace frameyap {
// Local inventory only; runs the shared Python verifier with argv (no shell).
int model_status(const CliOptions& options, const char* executable);
}
+114
View File
@@ -0,0 +1,114 @@
#include "paced_delivery.hpp"
#include <algorithm>
#include <stdexcept>
#include <utility>
namespace frameyap {
PacedDelivery::PacedDelivery(DeliveryFactory acquire, Clock clock,
std::function<void()> release_idle, Guard permitted)
: acquire_(std::move(acquire)), clock_(std::move(clock)),
release_idle_(std::move(release_idle)), permitted_(std::move(permitted)) {}
bool PacedDelivery::valid_guard() const {
try { return (!permitted_ || permitted_()) && task_->guard && task_->guard(); }
catch (...) { return false; }
}
void PacedDelivery::start(std::string text, bool enter, Guard guard,
std::function<void()> on_first_send) {
if (task_) throw std::runtime_error("Input already being paced");
if (text.empty() && !enter) throw std::runtime_error("Empty input action");
if (!text.empty() && (literal_text(text) != text))
throw std::runtime_error("Input must be a validated literal");
auto candidate = std::make_unique<Task>();
candidate->text = std::move(text);
candidate->enter = enter;
candidate->guard = std::move(guard);
candidate->on_first_send = std::move(on_first_send);
// Acquiring an IME is not delivery. Even after acquisition, focus must
// still match the authorization at this action's start.
try {
if ((permitted_ && !permitted_()) || !candidate->guard || !candidate->guard())
throw std::runtime_error("Input focus unavailable");
candidate->first_lease = acquire_();
if (!candidate->first_lease || (permitted_ && !permitted_()) || !candidate->guard())
throw std::runtime_error("Input focus changed during acquisition");
} catch (...) {
std::fill(candidate->text.begin(), candidate->text.end(), '\0');
throw;
}
retained_ = true;
task_ = std::move(candidate);
}
PacedDelivery::Outcome PacedDelivery::finish(DeliveryResult result, bool focus_lost) {
Outcome out{result, task_->began, focus_lost};
std::fill(task_->text.begin(), task_->text.end(), '\0');
task_.reset();
return out;
}
void PacedDelivery::release_if_idle() {
if (retained_ && !task_ && (!last_commit_ || clock_() - *last_commit_ >= gap)) {
if (release_idle_) {
try { release_idle_(); } catch (...) { return; } // retry next idle tick
}
retained_ = false;
}
}
std::optional<PacedDelivery::Outcome> PacedDelivery::tick() {
if (!task_) { release_if_idle(); return std::nullopt; }
if (last_commit_ && clock_() - *last_commit_ < gap) return std::nullopt;
auto& task = *task_;
// A focus loss before ANY send leaves the review unconsumed. After the
// first possible send, the remainder is discarded, not retargeted/retried.
if (!valid_guard()) return finish(task.began ? DeliveryResult::TextUncertain : DeliveryResult::Ignored, true);
std::unique_ptr<DeliveryLease> lease = std::move(task.first_lease);
if (!lease) {
try { lease = acquire_(); }
catch (...) { return finish(task.began ? (task.offset == task.text.size() ?
DeliveryResult::TextQueuedEnterUnavailable : DeliveryResult::TextUncertain) : DeliveryResult::Ignored); }
if (!lease) return finish(task.began ? DeliveryResult::TextUncertain : DeliveryResult::Ignored);
}
if (!valid_guard()) return finish(task.began ? DeliveryResult::TextUncertain : DeliveryResult::Ignored, true);
if (!task.began) {
// A callback consumes review immediately before a possibly ambiguous send.
try { if (task.on_first_send) task.on_first_send(); }
catch (...) { return finish(DeliveryResult::Ignored); }
task.began = true;
}
const bool sending_text = task.offset < task.text.size();
// Mark even a throwing send as attempted. Do not retry or submit Enter.
last_commit_ = clock_();
if (sending_text) {
std::size_t end = task.offset;
for (std::size_t count = 0; end < task.text.size() && count < max_codepoints; ++count) {
auto byte = static_cast<unsigned char>(task.text[end]);
end += byte < 0x80 ? 1 : byte < 0xe0 ? 2 : byte < 0xf0 ? 3 : 4;
}
std::string chunk;
try {
chunk = task.text.substr(task.offset, end - task.offset);
lease->text(chunk);
} catch (...) {
last_commit_ = clock_();
std::fill(chunk.begin(), chunk.end(), '\0');
return finish(DeliveryResult::TextUncertain);
}
last_commit_ = clock_();
std::fill(chunk.begin(), chunk.end(), '\0');
task.offset = end;
if (end == task.text.size() && !task.enter) return finish(DeliveryResult::TextQueued);
} else {
try { lease->enter(); }
catch (...) { last_commit_ = clock_(); return finish(DeliveryResult::EnterUncertain); }
last_commit_ = clock_();
return finish(DeliveryResult::EnterQueued);
}
return std::nullopt;
}
void PacedDelivery::cancel() {
if (task_) {
std::fill(task_->text.begin(), task_->text.end(), '\0');
task_.reset();
}
release_if_idle();
}
} // namespace frameyap
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "core.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <optional>
#include <string>
namespace frameyap {
// One explicitly authorized action (text and optional Enter) at a time. All
// callbacks run on the UI thread; no sleeping, worker thread, or input replay.
class PacedDelivery {
public:
using Clock = std::function<std::chrono::steady_clock::time_point()>;
using Guard = std::function<bool()>;
struct Outcome {
DeliveryResult result;
bool began; // first send may have reached the compositor
bool focus_lost; // a check failed before the next send
};
static constexpr auto gap = std::chrono::milliseconds(150);
static constexpr std::size_t max_codepoints = 24;
PacedDelivery(DeliveryFactory acquire, Clock clock = std::chrono::steady_clock::now,
std::function<void()> release_idle = {}, Guard permitted = {});
~PacedDelivery() { cancel(); }
// Validates/acquires before accepting a task. Failure throws without calling
// on_first_send; an existing review can still be offered for explicit Type.
// The first lease is held until the first due tick; subsequent leases are
// acquired one at a time after the previous lease has been released.
void start(std::string text, bool enter, Guard guard, std::function<void()> on_first_send);
std::optional<Outcome> tick(); // at most ONE commit; returns outcome only at termination
bool active() const { return bool(task_); }
bool began() const { return task_ && task_->began; }
void cancel(); // drops and scrubs the remaining literal; never sends Enter
private:
struct Task {
std::string text;
std::size_t offset = 0;
bool enter = false, began = false;
Guard guard;
std::function<void()> on_first_send;
std::unique_ptr<DeliveryLease> first_lease;
};
bool valid_guard() const;
Outcome finish(DeliveryResult result, bool focus_lost = false);
void release_if_idle();
DeliveryFactory acquire_;
Clock clock_;
std::function<void()> release_idle_;
Guard permitted_;
std::unique_ptr<Task> task_;
std::optional<std::chrono::steady_clock::time_point> last_commit_;
bool retained_ = false;
};
} // namespace frameyap
+157 -217
View File
@@ -1,6 +1,9 @@
#include "runtime.hpp"
#include "cli.hpp"
#include "audio.hpp"
#include "core.hpp"
#include "controller.hpp"
#include "backend_manager.hpp"
#include "config.hpp"
#include "overlay.hpp"
#include "text_input.hpp"
#include "worker.hpp"
@@ -8,16 +11,12 @@
#include "focus_guard.hpp"
#include <algorithm>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <filesystem>
#include <fcntl.h>
#include <iostream>
#include <csignal>
#include <memory>
#include <stdexcept>
#include <sys/file.h>
#include <sys/stat.h>
#include <thread>
#include <unistd.h>
namespace frameyap {
namespace {
@@ -31,240 +30,181 @@ public:
private:
TextInput input_;
};
std::string state_label(State state) {
switch (state) {
case State::Warming: return "Warming - on-device Redux CPU";
case State::Ready: return "Ready - hold X or click Record";
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";
class NativeAudio final : public ControllerAudio {
public:
bool open() const override { return audio_.open(); }
void prepare() override { audio_.prepare(); }
void start() override { audio_.start(); }
bool poll() override { return audio_.poll(); }
std::vector<float> finish() override { return audio_.finish(); }
void cancel() override { audio_.cancel(); }
void close() override { audio_.close(); }
int seconds() const override { return audio_.seconds(); }
private:
Audio audio_;
};
class NativeWorker final : public ControllerWorker {
public:
explicit NativeWorker(const Options& options) : options_(options) {}
void configure(std::string backend, std::string model, std::string manifest, std::string root) {
backend_ = std::move(backend); model_ = std::move(model);
manifest_ = std::move(manifest); root_ = std::move(root);
}
return {};
}
}
void start(bool advanced_debug) override {
const std::string script = backend_.empty() ? options_.worker : root_ + "/python/frameyap/backend_worker.py";
worker_.start(options_.python, script, backend_.empty() ? options_.model : model_, options_.threads,
advanced_debug, backend_, manifest_, root_);
}
bool ready() const override { return worker_.ready(); }
void submit(uint64_t id, const std::vector<float>& pcm) override { worker_.submit(id, pcm); }
std::optional<WorkerReply> poll() override { return worker_.poll(); }
void stop() override { worker_.stop(); }
private:
const Options& options_;
Worker worker_;
std::string backend_, model_, manifest_, root_;
};
class NativeFocus final : public ControllerFocus {
public:
bool arm() override { return guard_.arm(); }
bool valid() override { return guard_.valid(); }
private:
FocusGuard guard_;
};
} // namespace
int run(const Options& options) {
InstanceLock lock;
// Authorized sends retain the IME between per-action leases. Release it on
// every exit path, after any active delivery stack has unwound; never between
// text and its explicitly requested Enter. This is not a delivery receipt.
struct ReleaseInput {
~ReleaseInput() { try { TextInput::release_idle(); } catch (...) {} }
} release_input;
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.mount);
Worker worker;
Audio audio;
Session session;
std::string detail;
std::string status_note;
bool quit = false;
bool quick_open = false;
size_t quick_selected = 0;
bool advanced_debug = overlay.advanced_debug();
bool auto_insert = overlay.auto_insert();
std::unique_ptr<FocusGuard> armed_focus;
NativeWorker worker(options);
NativeAudio audio;
const DeliveryFactory acquire = [&]() -> std::unique_ptr<DeliveryLease> {
auto input = std::make_unique<NativeDeliveryLease>(options.socket);
if (interrupted) throw std::runtime_error("Input cancelled before delivery");
return input;
};
auto delivery_detail = [&](DeliveryResult result, bool submit) {
switch (result) {
case DeliveryResult::Ignored: break;
case DeliveryResult::TextQueued:
detail = "Text and trailing space queued to current focus. Enter remains explicit.";
status_note = "Text queued - Enter not sent"; break;
case DeliveryResult::EnterQueued:
detail = submit ? "Explicit Enter queued to current focus; not a delivery receipt."
: "Input queued to current focus; not a delivery receipt.";
status_note = "Enter queued - check destination"; break;
case DeliveryResult::TextUncertain:
detail = "Text delivery uncertain; Enter not sent. Not retried; check destination.";
status_note = "Text uncertain - Enter not sent"; break;
case DeliveryResult::EnterUncertain:
detail = "Enter delivery uncertain; not retried. Check destination.";
status_note = "Enter uncertain - check destination"; break;
case DeliveryResult::TextQueuedEnterUnavailable:
detail = "Text queued; Enter unavailable and not sent. Check destination.";
status_note = "Text queued - Enter unavailable"; break;
}
};
auto warm = [&] {
status_note.clear(); quick_open = false;
audio.close(); worker.stop(); session = Session{}; armed_focus.reset();
worker.start(options.python, options.worker, options.model, options.threads, advanced_debug);
detail = "Loading local model; microphone closed. Record again when Ready.";
};
auto stop_record = [&] {
if (session.state() != State::Recording) return;
auto pcm = audio.finish();
if (session.finish(pcm.size())) {
worker.submit(session.id(), pcm);
detail = armed_focus ? "Release complete; checking stable focus before auto insert." :
"Release complete. Review before inserting.";
} else { armed_focus.reset(); detail = "Short tap discarded (minimum 200ms)."; }
std::fill(pcm.begin(), pcm.end(), 0.0f);
};
auto start_record = [&] {
if (session.state() == State::Review || session.state() == State::Transcribing || session.state() == State::Warming) return;
if (!worker.ready()) { warm(); return; }
if (!audio.open()) audio.prepare(); // recovery only; normal PTT never opens the device
if (session.state() == State::Error) session.cancel();
if (!session.record()) return;
status_note.clear();
armed_focus.reset();
if (auto_insert) {
auto candidate = std::make_unique<FocusGuard>();
if (candidate->arm()) armed_focus = std::move(candidate);
}
audio.start();
detail = auto_insert && !armed_focus ? "Focus unverified; recording will require manual Insert." :
"Release to finish. Cancel discards. Maximum 20 seconds.";
};
try { warm(); }
catch (const std::exception& e) { session.fail(); detail = e.what(); }
while (!quit && !interrupted) {
if (armed_focus && !armed_focus->valid()) {
armed_focus.reset();
detail = "Focus changed or became uncertain; transcript will require manual Insert.";
}
try {
if (auto reply = worker.poll()) {
if (reply->id == session.id() && session.state() == State::Transcribing) {
if (!reply->error.empty()) {
// E is a request-local error. The child still owns its
// loaded model and can accept the next utterance.
session.fail(); detail = reply->error + "; model ready. Record to retry.";
// Worker::poll allows only fixed diagnostic labels here,
// never exception messages, audio, paths or recognized text.
std::cerr << "FrameYap worker: " << reply->error << '\n';
} else {
session.reply(reply->id, reply->text);
detail = session.text().empty() ? "No speech recognized; try again." :
"Focus your destination, then Insert. Cancel discards.";
if (session.state() == State::Review && auto_insert && armed_focus && armed_focus->valid()) {
// The exclusive IME lease can take time to acquire.
// Recheck *after* acquisition and before consuming the review.
const DeliveryFactory guarded = [&]() -> std::unique_ptr<DeliveryLease> {
auto lease = acquire();
if (!armed_focus || !armed_focus->valid())
throw std::runtime_error("Focus changed during input authorization");
return lease;
};
try {
const auto outcome = deliver_insert(session, guarded);
delivery_detail(outcome, false);
if (outcome == DeliveryResult::TextQueued)
detail = "Auto insert queued text + space to verified focus; never Enter. Not a delivery receipt.";
} catch (const std::exception&) {
detail = "Auto insert blocked; text kept for manual review. Check focus and Insert.";
}
}
armed_focus.reset();
}
}
PacedDelivery paced(acquire, std::chrono::steady_clock::now,
[] { TextInput::release_idle(); }, [] { return !interrupted; });
Controller controller(audio, worker, acquire, [] { return std::make_unique<NativeFocus>(); },
overlay.quick_inputs(), overlay.auto_insert(), overlay.advanced_debug(),
overlay.close_mic_when_idle(), &paced);
namespace fs = std::filesystem;
const auto root = asset_root(options.assets);
const auto manifest = options.manifest_dir.empty() ? root / "assets/backends" : fs::path(options.manifest_dir);
const auto service = root / "scripts/backend-service.py";
const bool generic = (options.worker.empty() || is_managed_worker(options.assets, options.worker)) &&
fs::is_regular_file(root / "python/frameyap/backend_worker.py") &&
fs::is_regular_file(service) && fs::is_directory(manifest);
const char* data = std::getenv("XDG_DATA_HOME");
const char* home = std::getenv("HOME");
const fs::path store = !options.model_store.empty() ? fs::path(options.model_store) :
data && fs::path(data).is_absolute() ? fs::path(data) / "frameyap/models" :
home && fs::path(home).is_absolute() ? fs::path(home) / ".local/share/frameyap/models" : root / "models";
const auto installer = fs::is_regular_file(root / "bin/install.sh") ? root / "bin/install.sh" : root / "install.sh";
std::unique_ptr<BackendManager> models;
std::string selected = options.backend.empty() ? load_config(default_config_path()).backend : options.backend;
size_t revision = 0;
std::string selection_note;
if (generic) {
models = std::make_unique<BackendManager>("python3", service.string(), manifest.string(),
store.string(), fs::is_regular_file(installer) ? installer.string() : "", options.model);
models->refresh();
controller.backend_changed(false, "Checking installed models offline. Recording unavailable until verified.");
} else controller.initialize();
bool previous_debug = overlay.advanced_debug();
while (!controller.quitting() && !interrupted) {
if (models) {
models->poll();
if (models->revision() != revision) {
revision = models->revision();
auto it = std::find_if(models->entries().begin(), models->entries().end(),
[&](const auto& entry) { return entry.id == selected; });
const bool verified = models->checked() && it != models->entries().end() &&
it->state == "installed_verified" && !models->busy();
if (verified) worker.configure(selected, models->model_path(selected), manifest.string(), root.string());
controller.backend_changed(verified, it == models->entries().end() ?
"Selected backend unavailable. Choose a listed model in Settings." :
"Selected model not installed/verified. Select Install in Settings.");
}
if (worker.ready()) session.ready();
} catch (const std::exception& e) {
armed_focus.reset(); audio.close(); worker.stop();
if (session.state() != State::Review && session.state() != State::Queued) session.fail();
detail = e.what(); // Preserve an already-correlated preview if the worker dies.
}
try {
// Prepare after model warm-up, not on PTT. Keep draining/discarding
// idle samples so neither a device transition nor old speech reaches
// the next clip. A capture failure still requires an explicit retry.
if (session.state() == State::Ready && !audio.open() && worker.ready()) audio.prepare();
if (audio.open() && audio.poll()) stop_record();
} catch (const std::exception& e) {
// A microphone failure is not a model failure.
audio.close(); session.fail(); detail = e.what();
armed_focus.reset();
controller.tick();
// Drawing precedes polling actions: Enter is disabled until Ready is visible.
auto panel = controller.panel();
if (models) {
panel.selected_backend = selected;
panel.model_note = selection_note.empty() ? models->note() : selection_note;
panel.model_busy = models->busy();
for (const auto& entry : models->entries())
panel.models.push_back({entry.id, entry.name,
entry.id == selected && controller.state() == State::Warming ? "loading" :
entry.id == selected && controller.state() == State::Ready ? "ready" :
entry.id == selected && controller.state() == State::Error && entry.state == "installed_verified" ? "failed" :
entry.state, entry.source, entry.license, entry.license_text, entry.attribution, entry.bytes,
entry.state == "installed_verified", entry.manifest_sha256});
}
// Drawing precedes input polling: Enter is disabled until Ready is visible.
const auto status = session.state() == State::Error && worker.ready()
? "Retry available - local model still loaded" : state_label(session.state());
if (quick_open && (session.state() == State::Recording || session.state() == State::Transcribing || session.state() == State::Warming || session.state() == State::Error)) quick_open = false;
Panel panel{quick_open ? "Quick chat" : status_note.empty() ? status : status_note, session.text(), detail,
session.state() != State::Error && session.state() != State::Warming && session.state() != State::Transcribing,
session.state() == State::Recording,
session.state() != State::Warming && session.state() != State::Transcribing && session.state() != State::Review};
panel.quick_open = quick_open;
panel.quick_selected = quick_selected;
panel.quick_inputs = overlay.quick_inputs();
if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s";
overlay.draw(panel);
auto actions = overlay.poll();
if (auto_insert != overlay.auto_insert()) {
auto_insert = overlay.auto_insert();
armed_focus.reset(); // a toggle never retroactively authorizes a capture
detail = auto_insert ? "Auto insert enabled for future clips only when X focus stays verified. Never Enter." :
"Auto insert disabled; review before Insert.";
}
if (advanced_debug != overlay.advanced_debug()) {
advanced_debug = overlay.advanced_debug();
// Consent changes take effect before any more work or delivery. The
// Settings warning makes the worker restart/cancellation explicit.
try { warm(); }
catch (const std::exception& e) { session.fail(); detail = e.what(); }
std::erase_if(actions, [](UiAction action) { return action != UiAction::Quit; });
}
for (auto action : actions) {
const auto model_actions = overlay.take_model_actions();
// A model-row click revokes the current clip/review even when it targets
// the selected row, is rejected while busy, or carries stale consent.
// Do not dispatch the Cancel/EndRecord generated when the overlay resets
// held PTT: it could turn an unavailable backend back into Ready.
for (const auto& change : model_actions) {
if (!models) continue;
try {
switch (action) {
case UiAction::Quit: quit = true; break;
case UiAction::Toggle:
case UiAction::Record:
if (quick_open) break;
if (session.state() == State::Recording) stop_record(); else start_record();
break;
case UiAction::BeginRecord: if (!quick_open) start_record(); break;
case UiAction::EndRecord: stop_record(); break;
case UiAction::Cancel: {
if (quick_open) { quick_open = false; break; }
status_note.clear();
auto state = session.state();
armed_focus.reset(); audio.cancel(); session.cancel(); detail = "Discarded. Idle microphone samples are discarded.";
if (state == State::Warming || state == State::Transcribing) {
audio.close(); worker.stop(); session.fail(); detail = "Cancelled. Record to reload local worker.";
} else if (state == State::Error && !worker.ready()) {
audio.close();
session.fail(); detail = "Worker unavailable. Record to reload local worker.";
if (change.install) {
if (change.id == selected) {
models->install(change.id, change.manifest_sha256);
selection_note.clear();
}
break;
} else if (change.id != selected && !models->busy()) {
const auto& entries = models->entries();
auto it = std::find_if(entries.begin(), entries.end(),
[&](const auto& entry) { return entry.id == change.id; });
if (it == entries.end()) continue;
selected = change.id;
selection_note = save_backend(default_config_path(), selected) ? "" :
"Backend preference not saved; selection lasts only this session.";
}
case UiAction::Insert:
if (!quick_open) delivery_detail(deliver_insert(session, acquire), false);
break;
case UiAction::Enter:
if (quick_open) {
quick_open = false; // one authorization, no repeat on a stale input
delivery_detail(deliver_quick(overlay.quick_inputs().at(quick_selected), acquire), true);
} else delivery_detail(deliver_enter(session, acquire), true);
break;
case UiAction::QuickChat:
if (panel.enabled && !panel.recording && !overlay.quick_inputs().empty()) {
if (quick_open) quick_selected = (quick_selected + 1) % overlay.quick_inputs().size();
else { quick_selected = 0; quick_open = true; }
}
break;
}
} catch (const std::exception& e) {
// Input lease failures preserve preview. Capture failures close
// only the microphone; submit failures stop their own child if
// the IPC stream was partially written.
if (session.state() == State::Recording || session.state() == State::Transcribing) {
audio.close(); session.fail();
status_note.clear();
} else {
status_note = "Input unavailable - check destination";
}
detail = e.what();
} catch (const std::exception&) {
selection_note = "Model metadata changed or installer unavailable. Recheck and confirm again.";
}
if (quit || interrupted) break;
}
if (models && !model_actions.empty()) {
const auto& entries = models->entries();
auto it = std::find_if(entries.begin(), entries.end(),
[&](const auto& entry) { return entry.id == selected; });
const bool verified = models->checked() && !models->busy() &&
it != entries.end() && it->state == "installed_verified";
if (verified) worker.configure(selected, it->model_path, manifest.string(), root.string());
controller.backend_changed(verified, verified ? "" :
"Selected model unavailable or being checked. Recording disabled until offline verification.");
}
bool debug_changed = overlay.advanced_debug() != previous_debug;
controller.settings(overlay.auto_insert(), overlay.advanced_debug(), overlay.close_mic_when_idle());
// Debug consent and model actions cancel work before any stale input.
if (debug_changed || !model_actions.empty()) {
for (auto action : actions) if (action == UiAction::Quit) controller.action(action);
} else {
for (auto action : actions) {
controller.action(action);
if (controller.quitting() || interrupted) break;
}
}
previous_debug = overlay.advanced_debug();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
audio.close(); session.cancel(); worker.stop();
controller.shutdown();
if (models) models->cancel();
return 0;
}
}
} // namespace frameyap
+1
View File
@@ -5,6 +5,7 @@
namespace frameyap {
struct Options {
std::string assets, font, python = "python3", worker, model, socket;
std::string backend, manifest_dir, model_store; // optional overrides; empty loads saved choice / XDG store
int threads = 2;
std::optional<Mount> mount; // saved preference, or world on first launch
};
+70 -8
View File
@@ -1,5 +1,6 @@
#include "text_input.hpp"
#include "core.hpp"
#include "paced_delivery.hpp"
#include "gamescope-input-method-client.h"
#include <wayland-client.h>
#include <chrono>
@@ -7,9 +8,11 @@
#include <cerrno>
#include <poll.h>
#include <stdexcept>
#include <thread>
namespace frameyap {
struct TextInput::Impl {
std::string socket;
wl_display* display = nullptr;
wl_registry* registry = nullptr;
wl_seat* seat = nullptr;
@@ -17,7 +20,19 @@ struct TextInput::Impl {
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;
bool done = false, unavailable = false, ambiguous = false;
std::chrono::steady_clock::time_point retire_after{};
// The connection remains owned by retained() after an authorized send, not
// by a temporary delivery lease. Do not evict it on a compositor sync:
// Gamescope may still have keystrokes/keymap work queued for the target.
static std::shared_ptr<Impl>& retained() {
static std::shared_ptr<Impl> connection;
return connection;
}
static std::weak_ptr<Impl>& active() {
static std::weak_ptr<Impl> lease;
return lease;
}
~Impl() {
// Local proxy destruction then socket close avoids a blocking flush on teardown.
if (ime) wl_proxy_destroy(reinterpret_cast<wl_proxy*>(ime));
@@ -80,20 +95,43 @@ struct TextInput::Impl {
wl_callback_destroy(callback);
if (unavailable) throw std::runtime_error("Gamescope IME unavailable (Steam keyboard may own it)");
}
void check_authorization() const {
void check_authorization(bool used) const {
if (used || unavailable || !done) throw std::runtime_error("Input authorization is no longer valid");
}
void commit() {
check_authorization();
used = true;
// Also cover uncertain sends. Normal idle ticks already wait this long;
// shutdown needs the same bounded grace without sending anything else.
struct Cooldown {
Impl& self;
~Cooldown() { self.retire_after = std::chrono::steady_clock::now() + PacedDelivery::gap; }
} cooldown{*this};
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>()) {
TextInput::TextInput(const std::string& socket) {
if (socket.empty()) throw std::runtime_error("Specify the Gamescope Wayland socket with --socket");
if (!Impl::active().expired()) throw std::runtime_error("Gamescope IME lease already active");
if (Impl::retained()) {
if (Impl::retained()->socket != socket)
throw std::runtime_error("Gamescope socket changed; release idle IME before retargeting");
impl_ = Impl::retained();
// A fresh compositor roundtrip observes unavailable/seat removal, not
// delivery to the destination application. Failed acquisition must not
// consume a review, nor automatically replay earlier uncertain input.
try { impl_->roundtrip(); }
catch (...) { Impl::retained().reset(); throw; }
if (!impl_->done || impl_->unavailable) {
Impl::retained().reset();
throw std::runtime_error("Gamescope IME no longer ready");
}
Impl::active() = impl_;
return;
}
impl_ = std::make_shared<Impl>();
auto& p = *impl_;
p.socket = socket;
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);
@@ -109,17 +147,41 @@ TextInput::TextInput(const std::string& socket) : impl_(std::make_unique<Impl>()
gamescope_input_method_add_listener(p.ime, &ime_listener, &p);
p.roundtrip();
if (!p.done) throw std::runtime_error("Gamescope IME not ready");
Impl::active() = impl_;
}
TextInput::~TextInput() {
if (Impl::active().lock() == impl_) Impl::active().reset();
}
void TextInput::release_idle() {
if (!Impl::active().expired()) throw std::runtime_error("Cannot release an active Gamescope IME lease");
if (Impl::retained()) std::this_thread::sleep_until(Impl::retained()->retire_after);
Impl::retained().reset();
}
TextInput::~TextInput() = default;
void TextInput::text(const std::string& literal) {
auto validated = literal_text(literal);
// A valid whole transcript can yield a batch containing only spaces. Keep
// those bytes; only whole ASR replies use the all-whitespace => empty policy.
if (validated.empty() && !literal.empty() && literal.find_first_not_of(' ') == std::string::npos)
validated = literal;
if (validated.empty()) throw std::runtime_error("No text to insert");
impl_->check_authorization();
// Gamescope's temporary keymap has a finite rotating keycode table. A
// 4080-byte Wayland-valid payload can still corrupt its *front* before
// commit. The UI scheduler sends at most 24 Unicode codepoints per commit;
// reject accidental bypasses here, including the old synchronous path.
std::size_t codepoints = 0;
for (unsigned char byte : validated)
if ((byte & 0xc0) != 0x80) ++codepoints;
if (codepoints > 24) throw std::runtime_error("Input batch exceeds 24 codepoints");
impl_->check_authorization(used_);
used_ = true;
Impl::retained() = impl_; // Retain even if the roundtrip fails ambiguously.
gamescope_input_method_set_string(impl_->ime, validated.c_str());
impl_->commit();
}
void TextInput::enter() {
impl_->check_authorization();
impl_->check_authorization(used_);
used_ = true;
Impl::retained() = impl_;
gamescope_input_method_set_action(impl_->ime, GAMESCOPE_INPUT_METHOD_ACTION_SUBMIT);
impl_->commit();
}
+11 -4
View File
@@ -2,18 +2,25 @@
#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.
// Single-use authorization for an explicit user action. Sends retain the IME
// connection between leases: a compositor sync does not drain synthetic
// key events in the destination. This temporarily excludes other seat IMEs.
// Construction checks 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 text(const std::string& literal); // one validated batch, at most 24 codepoints
void enter();
// Release the retained, idle connection when disabling/stopping FrameYap.
// Must have no active lease. Shutdown may wait the remaining <=150ms cooldown;
// normal paced idle release has already waited. This is NOT a delivery ack.
static void release_idle();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
std::shared_ptr<Impl> impl_;
bool used_ = false;
};
}
+38 -4
View File
@@ -1,7 +1,9 @@
if(NOT DEFINED APP OR NOT DEFINED EXPECTED_VERSION)
message(FATAL_ERROR "APP and EXPECTED_VERSION are required")
endif()
if(NOT EXPECTED_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[1-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$")
message(FATAL_ERROR "Invalid numeric build version: ${EXPECTED_VERSION}")
endif()
foreach(mode IN ITEMS default help version invalid extra)
set(args)
set(expected_exit 0)
@@ -22,14 +24,46 @@ 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 "frameyap ${EXPECTED_VERSION}\n")
string(REPLACE "\n" ";" lines "${output}")
list(GET lines 0 first_line)
if(NOT first_line STREQUAL "frameyap ${EXPECTED_VERSION}")
message(FATAL_ERROR "Unexpected version: ${output}")
endif()
list(LENGTH lines count)
if(count GREATER 1)
list(GET lines 1 second_line)
if(NOT second_line MATCHES "^git [0-9a-f]+( \\(uncommitted changes\\))?$")
message(FATAL_ERROR "Unexpected developer information: ${output}")
endif()
endif()
if(count GREATER 2)
list(GET lines 2 trailing_line)
if(NOT trailing_line STREQUAL "" OR count GREATER 3)
message(FATAL_ERROR "Extra version output: ${output}")
endif()
endif()
elseif(expected_exit EQUAL 2)
if(NOT error MATCHES "Unsupported arguments")
message(FATAL_ERROR "Missing rejection diagnostic")
endif()
elseif(NOT output MATCHES "No device access unless")
message(FATAL_ERROR "Missing explicit runtime opt-in status")
elseif(NOT output MATCHES "No device access unless" OR
NOT output MATCHES "--model-store DIR" OR NOT output MATCHES "manifest-managed Models UI")
message(FATAL_ERROR "Missing explicit runtime opt-in or generic backend help")
endif()
endforeach()
# A namesake worker outside the installation root is custom, even if its
# basename is worker.py. Reject this before runtime setup (on offline builds,
# before the generic "runtime disabled" diagnostic).
foreach(extra IN ITEMS "--backend;redux" "")
set(args --run --assets /app/assets/ --worker /other/worker.py)
if(NOT extra STREQUAL "")
list(APPEND args ${extra})
endif()
execute_process(COMMAND "${APP}" ${args}
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error)
if(NOT result EQUAL 2 OR NOT error MATCHES "custom --worker" OR
error MATCHES "runtime disabled" OR NOT output STREQUAL "")
message(FATAL_ERROR "Custom worker bypassed syntax validation: ${result}: ${error}")
endif()
endforeach()
+47
View File
@@ -0,0 +1,47 @@
"""Verify build metadata suppression without touching the repository's tags/history."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class GitVersionTest(unittest.TestCase):
def test_clean_matching_tag_suppresses_git_line(self):
if not shutil.which('git'):
self.skipTest('Git unavailable')
# Isolate only CMake's version block in a tiny, disposable Git tree.
source = (ROOT / 'CMakeLists.txt').read_text()
header = source.split('message(STATUS "FrameYap build version:', 1)[0]
version = '2.3.202601020304'
with tempfile.TemporaryDirectory(prefix='frameyap-tag-test-') as temp:
root = Path(temp)
(root / 'CMakeLists.txt').write_text(header + '\nfile(WRITE "${CMAKE_CURRENT_BINARY_DIR}/git-info.txt" "${FRAMEYAP_GIT_INFO}")\n')
# CMake's source-tree dirty check includes untracked files. Ignore
# only the disposable build directory in this mini repository.
(root / '.gitignore').write_text('build/\n')
def git(*args):
subprocess.run(['git', '-C', str(root), *args], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
def configure():
subprocess.run(['cmake', '-S', str(root), '-B', str(root / 'build'),
'-DFRAMEYAP_VERSION=' + version], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
return (root / 'build/git-info.txt').read_text()
git('init', '-q')
git('add', 'CMakeLists.txt', '.gitignore')
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid',
'commit', '-qm', 'fixture')
git('tag', 'v' + version)
self.assertEqual(configure(), '')
(root / 'dirty.txt').write_text('local change\n')
self.assertRegex(configure(), r'^git [0-9a-f]+ \(uncommitted changes\)$')
(root / 'dirty.txt').unlink()
git('tag', '-d', 'v' + version)
self.assertRegex(configure(), r'^git [0-9a-f]+$')
if __name__ == '__main__':
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
#include "cli.hpp"
#include <cassert>
#include <initializer_list>
#include <string>
#include <vector>
using namespace frameyap;
namespace {
CliOptions parse(std::initializer_list<const char*> args) {
std::vector<const char*> argv{"frameyap"};
argv.insert(argv.end(), args.begin(), args.end());
return parse_cli(static_cast<int>(argv.size()), argv.data());
}
bool rejects(std::initializer_list<const char*> args) {
try { parse(args); } catch (const CliError&) { return true; }
return false;
}
}
int main() {
assert(parse({}).mode == CliMode::Help);
assert(parse({"--help"}).mode == CliMode::Help);
assert(parse({"--version"}).mode == CliMode::Version);
assert(parse({"--check-input", "--socket", "display-0"}).socket == "display-0");
auto run = parse({"--run", "--assets", "/app/assets/", "--model", "/model",
"--worker", "/app/python/frameyap/worker.py",
"--python", "/python", "--threads", "4", "--head"});
assert(run.mode == CliMode::Run && run.head && run.threads == "4" &&
is_managed_worker(run.assets, run.worker));
assert(asset_root("/app/assets/") == "/app");
assert(asset_root("/app/assets") == "/app");
assert(is_managed_worker("/app/assets/", "/app/python/frameyap/./worker.py"));
assert(is_managed_worker("/app/assets", "/app/python/frameyap/worker.py"));
assert(!is_managed_worker("/app/assets/", "/elsewhere/worker.py"));
auto generic = parse({"--run", "--assets", "/assets", "--backend", "redux",
"--manifest-dir", "/models/manifests", "--model-store", "/models/store"});
assert(generic.mode == CliMode::Run && generic.worker.empty() && generic.model.empty());
assert(generic.backend == "redux" && generic.manifest_dir == "/models/manifests" &&
generic.model_store == "/models/store");
assert(parse({"--run", "--assets", "/assets"}).worker.empty());
assert(parse({"--run", "--assets", "/app/assets/", "--worker", "/app/python/frameyap/worker.py",
"--backend", "redux"}).model.empty());
assert(parse({"--run", "--assets", "/assets", "--worker", "/tmp/custom.py", "--model", "/weights"}).model == "/weights");
assert(parse({"--run", "--assets", "/assets", "--backend", "a_1-"}).backend == "a_1-");
assert(parse({"--register", "file", "--autostart"}).autostart);
assert(parse({"--unregister", "file"}).manifest == "file");
assert(parse({"--check-model", "redux", "--model-dir", "/tmp/m", "--json"}).json);
assert(parse({"--list-models", "--manifest-dir", "/tmp/manifests"}).manifest_dir == "/tmp/manifests");
for (auto args : {
std::vector<const char*>{"--help", "--version"}, {"--record"}, {"--run", "--threads", "3"},
{"--run", "--head", "--mount", "world"}, {"--check-input", "--assets", "/tmp"},
{"--check-overlay", "--socket", "test"}, {"--register"}, {"--check-model", "redux"},
{"--check-model", "--json"}, {"--list-models", "--model-dir"},
{"--list-models", "--json", "--json"}, {"--run", "--assets", "a", "--assets", "b"},
{"--run", "--assets", "--font"}, {"--unregister", "file", "--autostart"},
{"--list-models", "--check-model", "redux"}, {"--run", "--model-dir", "a"},
{"--list-models", "--backend", "redux"}, {"--check-overlay", "--model-store", "/tmp"},
{"--run", "--assets", "a", "--worker", "custom.py"},
{"--run", "--assets", "a", "--worker", "custom.py", "--model", "m", "--backend", "redux"},
{"--run", "--assets", "/app/assets/", "--worker", "/other/worker.py"},
{"--run", "--assets", "/app/assets/", "--worker", "/other/worker.py", "--backend", "redux"},
{"--run", "--assets", "/app/assets/", "--worker", "/other/worker.py", "--model", "m", "--backend", "redux"},
{"--run", "--assets", "/app/assets/", "--worker", "worker.py"},
{"--run", "--worker", "/app/python/frameyap/worker.py", "--backend", "redux"},
{"--run", "--backend", "../redux"}, {"--run", "--backend", "Redux"},
{"--run", "--backend", "a.b"}, {"--run", "--backend", "-bad"},
{"--run", "--backend", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvw"},
{"--run", "--model-store", "relative"}, {"--run", "--manifest-dir", "relative"},
{"--run", "--manifest-dir", "/tmp/../etc"}, {"--run", "--model-store", "/tmp/./files"},
{"--run", "--model-store", "/tmp/\nfiles"}, {"--run", "--backend", "redux", "--backend", "redux"},
{"--run", "--model-store"}, {"--run", "--model-store", "/store", "--model-store", "/other"},
{"--check-model", "Redux", "--model-dir", "/models"}
}) {
std::vector<const char*> argv{"frameyap"};
argv.insert(argv.end(), args.begin(), args.end());
bool failed = false;
try { parse_cli(static_cast<int>(argv.size()), argv.data()); }
catch (const CliError&) { failed = true; }
assert(failed);
}
assert(rejects({"--version", "extra"}));
}
+377
View File
@@ -0,0 +1,377 @@
#include "controller.hpp"
#include <cassert>
#include <chrono>
#include <deque>
#include <stdexcept>
#include <string>
using namespace frameyap;
namespace {
struct AudioFake final : ControllerAudio {
bool opened = false, timeout = false;
int prepares = 0, closes = 0, starts = 0, polls = 0, finishes = 0;
size_t samples = 3200;
bool open() const override { return opened; }
void prepare() override { opened = true; ++prepares; }
void start() override { assert(opened); ++starts; }
bool poll() override { ++polls; return timeout; }
std::vector<float> finish() override { ++finishes; timeout = false; return std::vector<float>(samples); }
void cancel() override {}
void close() override { if (opened) ++closes; opened = false; }
int seconds() const override { return 0; }
};
struct WorkerFake final : ControllerWorker {
bool loaded = false;
int starts = 0, stops = 0, submissions = 0;
bool debug = false;
uint64_t request = 0;
std::deque<WorkerReply> replies;
void start(bool advanced) override { loaded = true; ++starts; debug = advanced; }
bool ready() const override { return loaded; }
void submit(uint64_t id, const std::vector<float>& pcm) override {
assert(loaded && pcm.size() == 3200); request = id; ++submissions;
}
std::optional<WorkerReply> poll() override {
if (replies.empty()) return std::nullopt;
auto result = replies.front(); replies.pop_front(); return result;
}
void stop() override { loaded = false; ++stops; replies.clear(); }
};
struct FocusState { bool armed = true, valid = true; int checks = 0, destroyed = 0; };
struct FocusFake final : ControllerFocus {
explicit FocusFake(FocusState& state) : state(state) {}
~FocusFake() override { ++state.destroyed; }
bool arm() override { return state.armed; }
bool valid() override { ++state.checks; return state.valid; }
FocusState& state;
};
struct InputState {
int text = 0, enter = 0, acquisitions = 0;
bool unavailable = false, fail_text = false, lose_focus_on_acquire = false;
std::string sent;
std::vector<std::string> events;
};
struct Lease final : DeliveryLease {
explicit Lease(InputState& state) : state(state) {}
void text(const std::string& literal) override {
++state.text; state.sent = literal; state.events.push_back(literal);
if (state.fail_text) throw std::runtime_error("ambiguous text send");
}
void enter() override { ++state.enter; state.events.push_back("<Enter>"); }
InputState& state;
};
struct Fixture {
AudioFake audio;
WorkerFake worker;
FocusState focus;
InputState input;
DeliveryFactory acquire() {
return [this]() -> std::unique_ptr<DeliveryLease> {
++input.acquisitions;
if (input.lose_focus_on_acquire) focus.valid = false;
if (input.unavailable) throw std::runtime_error("input unavailable");
return std::make_unique<Lease>(input);
};
}
Controller make(bool automatic = false, bool close_mic = false, PacedDelivery* paced = nullptr) {
return Controller(audio, worker, acquire(),
[this]() -> std::unique_ptr<ControllerFocus> { return std::make_unique<FocusFake>(focus); },
{"Hi", "Next"}, automatic, false, close_mic, paced);
}
};
void ready(Controller& c) { c.initialize(); c.tick(); assert(c.state() == State::Ready); }
void release(Controller& c, Fixture& f) {
c.action(UiAction::BeginRecord);
assert(c.state() == State::Recording);
c.action(UiAction::EndRecord);
assert(c.state() == State::Transcribing && f.worker.submissions > 0);
}
void result(Controller& c, Fixture& f, std::string text) {
f.worker.replies.push_back({f.worker.request, std::move(text), {}});
c.tick();
}
}
int main() {
{
Fixture f; auto c = f.make(false); ready(c);
release(c, f); result(c, f, "pending preview");
assert(c.state() == State::Review);
c.backend_changed(false, "Model not installed");
assert(c.state() == State::Error && !c.panel().record_available);
assert(c.panel().transcript.empty() && !f.worker.loaded && !f.audio.opened);
int previous_starts = f.worker.starts;
c.action(UiAction::BeginRecord);
assert(f.worker.starts == previous_starts && f.input.text == 0);
c.action(UiAction::Insert); assert(f.input.text == 0);
c.backend_changed(true); c.tick();
assert(c.state() == State::Ready && f.worker.starts == previous_starts + 1);
release(c, f);
c.backend_changed(false);
f.worker.replies.push_back({f.worker.request, "stale", {}});
c.tick(); assert(c.panel().transcript.empty() && f.input.text == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
c.action(UiAction::BeginRecord); // held PTT, auto-focus armed
assert(c.state() == State::Recording && f.focus.destroyed == 0);
c.backend_changed(true); // selected row clicked again: restart, never retain held clip
assert(c.state() == State::Warming && !f.audio.opened && f.focus.destroyed == 1);
int submitted = f.worker.submissions;
c.action(UiAction::EndRecord); // old PTT release cannot submit after restart
c.tick(); assert(c.state() == State::Ready && f.worker.submissions == submitted);
c.action(UiAction::Insert); assert(f.input.text == 0);
release(c, f);
f.focus.valid = false; result(c, f, "review to discard");
assert(c.state() == State::Review && f.input.text == 0);
c.backend_changed(false, "Busy or missing selected model");
assert(c.state() == State::Error && !c.panel().record_available && c.panel().transcript.empty());
int starts = f.worker.starts;
c.action(UiAction::Cancel); // a stale Cancel cannot re-enable an unavailable model
c.tick(); c.action(UiAction::BeginRecord); c.action(UiAction::Enter);
assert(c.state() == State::Error && f.worker.starts == starts &&
f.input.enter == 0 && f.input.text == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(); ready(c);
release(c, f); result(c, f, "review to discard");
assert(c.state() == State::Review);
c.backend_changed(true); // current selected row: revoke review and restart
assert(c.state() == State::Warming && c.panel().transcript.empty());
c.action(UiAction::Insert); c.action(UiAction::EndRecord);
assert(f.input.text == 0 && f.worker.submissions == 1);
c.shutdown();
}
{
Fixture f; auto c = f.make(); ready(c);
assert(f.audio.prepares == 1 && f.audio.opened); // default idle drain
c.tick(); assert(f.audio.polls >= 2);
release(c, f);
c.action(UiAction::Enter); // Never submit while transcribing.
assert(f.input.enter == 0 && f.input.text == 0);
// Malformed controls and bad UTF-8 are request failures, not worker failures.
result(c, f, std::string("bad\x1b[31m"));
assert(c.state() == State::Error && f.worker.loaded && f.worker.starts == 1);
assert(c.panel().detail.find("transcription failed") != std::string::npos);
assert(c.panel().transcript.empty() && f.input.text == 0);
release(c, f);
result(c, f, std::string("bad\xc3\x28"));
assert(c.state() == State::Error && f.worker.loaded && f.worker.starts == 1);
release(c, f);
f.worker.replies.push_back({f.worker.request, {}, "transcription failed"});
c.tick();
assert(c.state() == State::Error && f.worker.loaded && f.worker.starts == 1);
release(c, f); result(c, f, "hello");
assert(c.state() == State::Review && c.panel().transcript == "hello");
assert(f.input.text == 0 && f.input.enter == 0);
c.action(UiAction::Insert);
assert(f.input.text == 1 && f.input.sent == "hello " && f.input.enter == 0);
c.action(UiAction::Insert); assert(f.input.text == 1); // at most once
release(c, f); result(c, f, std::string(4096, 'x'));
c.action(UiAction::Insert);
assert(f.input.sent.size() == 4096 && f.input.sent.back() == 'x');
assert(c.panel().detail.find("trailing space if it fits") != std::string::npos);
c.shutdown();
}
{
Fixture f; auto c = f.make(); ready(c);
c.action(UiAction::QuickChat);
assert(c.panel().quick_open && c.panel().quick_selected == 0);
c.action(UiAction::Record); assert(c.state() == State::Ready);
c.action(UiAction::QuickChat); assert(c.panel().quick_selected == 1);
c.action(UiAction::Cancel); assert(!c.panel().quick_open && f.input.text == 0);
c.action(UiAction::QuickChat); c.action(UiAction::Enter);
assert(f.input.text == 1 && f.input.sent == "Hi" && f.input.enter == 1);
assert(!c.panel().quick_open);
c.action(UiAction::Cancel); // close quick chat never dispatches a stale Enter
assert(f.input.enter == 1);
c.action(UiAction::QuickChat); f.input.unavailable = true;
c.action(UiAction::Enter);
assert(!c.panel().quick_open && f.input.enter == 1);
c.action(UiAction::Enter); // explicit new Enter alone, still unavailable
assert(f.input.enter == 1);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f); result(c, f, "auto");
assert(c.state() == State::Queued && f.input.sent == "auto " && f.input.enter == 0);
assert(c.panel().detail.find("never Enter") != std::string::npos);
c.tick(); assert(f.input.text == 1); // duplicate delivery prohibited
release(c, f);
f.focus.valid = false;
result(c, f, "review");
assert(c.state() == State::Review && f.input.text == 1);
c.action(UiAction::Insert);
assert(f.input.sent == "review " && f.input.text == 2 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f);
f.input.lose_focus_on_acquire = true;
result(c, f, "focus changed during lease");
assert(c.state() == State::Review && f.input.text == 0);
f.input.lose_focus_on_acquire = false;
c.action(UiAction::Insert);
assert(f.input.text == 1 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f);
f.input.fail_text = true;
result(c, f, "ambiguous");
assert(c.state() == State::Queued && f.input.text == 1 && f.input.enter == 0);
c.action(UiAction::Insert);
assert(f.input.text == 1); // uncertain delivery cannot be retried
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f);
f.input.unavailable = true; result(c, f, "keep");
assert(c.state() == State::Review && f.input.text == 0);
f.input.unavailable = false;
c.action(UiAction::Insert);
assert(f.input.sent == "keep " && f.input.enter == 0);
c.settings(false, false, false);
release(c, f);
c.action(UiAction::Cancel); // cancel outstanding inference; do not deliver late replies
assert(c.state() == State::Error && !f.worker.loaded);
f.worker.replies.push_back({f.worker.request, "stale", {}});
c.tick(); assert(c.panel().transcript.empty() && f.input.text == 1);
c.action(UiAction::Record); c.tick(); assert(c.state() == State::Ready);
f.audio.samples = 3199;
c.action(UiAction::Record); c.action(UiAction::EndRecord);
assert(c.state() == State::Ready && f.worker.submissions == 2);
c.shutdown();
}
{
Fixture f; auto c = f.make(false); ready(c);
c.action(UiAction::Record);
c.settings(true, false, true); // neither setting retroactively arms focus
c.action(UiAction::EndRecord);
assert(!f.audio.opened);
result(c, f, "manual");
assert(c.state() == State::Review && f.input.text == 0 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(false, true); ready(c);
assert(!f.audio.opened && f.audio.prepares == 0);
release(c, f);
assert(f.audio.prepares == 1 && !f.audio.opened && f.audio.closes == 1);
result(c, f, "text"); assert(!f.audio.opened && c.state() == State::Review);
c.action(UiAction::Insert);
c.action(UiAction::Record); assert(f.audio.opened);
c.action(UiAction::Cancel); assert(!f.audio.opened);
c.settings(false, false, false); c.tick();
assert(f.audio.opened); // default resumes idle drain on next Ready tick
c.settings(false, true, true);
assert(f.worker.debug && f.worker.starts == 2 && !f.audio.opened);
c.tick(); assert(c.state() == State::Ready && !f.audio.opened);
c.shutdown();
}
// Native-style scheduler: authorization is captured at action start, not
// reacquired at the end of an asynchronous multi-commit task.
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(false, false, &paced); ready(c);
release(c, f); result(c, f, std::string(81, 'x'));
c.action(UiAction::Enter);
assert(c.state() == State::Review && f.input.events.empty() && !c.panel().record_available);
c.action(UiAction::Record); c.action(UiAction::Insert);
assert(c.state() == State::Review && f.input.events.empty());
c.tick();
assert(c.state() == State::Queued && f.input.events.size() == 1 && f.input.events[0].size() == 24);
// Runtime reapplies settings every frame; unchanged values must not say
// delivery stopped while the remaining batches are still active.
c.settings(false, false, false);
assert(paced.active() && c.panel().status == "Input pacing - not a delivery receipt");
assert(c.panel().detail.find("dropped") == std::string::npos);
c.action(UiAction::Record); assert(c.state() == State::Queued);
for (int i = 0; i < 4; ++i) {
now += std::chrono::milliseconds(150); c.tick();
}
assert(f.input.events.size() == 5 && f.input.enter == 1);
std::string joined;
for (int i = 0; i < 4; ++i) joined += f.input.events[i];
assert(joined == std::string(81, 'x') + " " && f.input.events.back() == "<Enter>");
c.action(UiAction::Insert); assert(f.input.events.size() == 5);
// A separate Enter press is a new action but still obeys the gap.
c.action(UiAction::Enter); c.tick(); assert(f.input.enter == 1);
now += std::chrono::milliseconds(150); c.tick(); assert(f.input.enter == 2);
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(false, false, &paced); ready(c);
release(c, f); result(c, f, "review preserved");
c.action(UiAction::Insert); f.focus.valid = false; c.tick();
assert(c.state() == State::Review && c.panel().transcript == "review preserved" && f.input.events.empty());
f.focus.valid = true; f.input.lose_focus_on_acquire = true;
c.action(UiAction::Insert);
assert(c.state() == State::Review && f.input.events.empty());
f.input.lose_focus_on_acquire = false; f.focus.valid = true;
c.action(UiAction::Insert); c.tick();
assert(c.state() == State::Queued && f.input.text == 1 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(true, false, &paced); ready(c);
release(c, f); result(c, f, std::string(81, 'a'));
assert(c.state() == State::Queued && f.input.text == 1 && f.input.enter == 0);
f.focus.valid = false; now += std::chrono::milliseconds(150); c.tick();
assert(f.input.text == 1 && f.input.enter == 0 && !paced.active());
assert(c.panel().detail.find("Focus changed during pacing") != std::string::npos);
f.focus.valid = true;
c.action(UiAction::Insert); assert(f.input.text == 1); // auto cannot silently retarget
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(true, false, &paced); ready(c);
f.input.lose_focus_on_acquire = true;
release(c, f); result(c, f, "auto review");
assert(c.state() == State::Review && c.panel().transcript == "auto review" && f.input.events.empty());
f.focus.valid = true; f.input.lose_focus_on_acquire = false;
c.action(UiAction::Insert); c.tick();
assert(c.state() == State::Queued && f.input.sent == "auto review ");
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(false, false, &paced); ready(c);
release(c, f); result(c, f, std::string(81, 'a'));
c.action(UiAction::Enter); c.tick();
c.action(UiAction::Cancel); now += std::chrono::milliseconds(150); c.tick();
assert(f.input.text == 1 && f.input.enter == 0 && c.panel().transcript.empty());
release(c, f); result(c, f, std::string(81, 'b'));
c.action(UiAction::Enter); now += std::chrono::milliseconds(150); c.tick();
c.settings(false, true, false); now += std::chrono::milliseconds(150); c.tick();
assert(f.input.text == 2 && f.input.enter == 0 && c.state() == State::Ready);
release(c, f); result(c, f, std::string(81, 'c'));
c.action(UiAction::Enter);
c.backend_changed(true); // model restart drops a task before first send
now += std::chrono::milliseconds(150); c.tick();
assert(c.state() == State::Ready && f.input.text == 2 && f.input.enter == 0);
release(c, f); result(c, f, std::string(81, 'd'));
c.action(UiAction::Enter); c.action(UiAction::Quit);
now += std::chrono::milliseconds(150); c.tick();
assert(c.quitting() && f.input.text == 2 && f.input.enter == 0);
c.shutdown();
}
}
+5 -6
View File
@@ -146,14 +146,13 @@ void delivery_checks() {
}
{
auto s = review(std::string(4096, 'a')); FakeDelivery fake;
try { (void)deliver_enter(s, fake.factory()); CHECK(false); }
catch (const std::runtime_error&) {}
CHECK(s.state() == State::Review && s.text().size() == 4096);
CHECK(fake.acquisitions == 0 && fake.events.empty());
CHECK(deliver_enter(s, fake.factory()) == DeliveryResult::EnterQueued);
CHECK(fake.events == std::vector<std::string>({"text:" + std::string(4096, 'a'), "enter"}));
CHECK(s.state() == State::Queued && fake.acquisitions == 2);
auto with_existing_space = review(std::string(4095, 'a') + " ");
CHECK(deliver_insert(with_existing_space, fake.factory()) == DeliveryResult::TextQueued);
CHECK(fake.events[0] == "text:" + std::string(4095, 'a') + " ");
CHECK(with_existing_space.state() == State::Queued && fake.acquisitions == 1);
CHECK(fake.events[2] == "text:" + std::string(4095, 'a') + " ");
CHECK(with_existing_space.state() == State::Queued && fake.acquisitions == 3);
}
}
int main() {
+43
View File
@@ -0,0 +1,43 @@
if(NOT DEFINED APP OR NOT DEFINED ROOT OR NOT DEFINED STAGE)
message(FATAL_ERROR "APP, ROOT and STAGE required")
endif()
function(check app expected regex)
execute_process(COMMAND "${app}" ${ARGN}
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error TIMEOUT 15)
if(NOT "${result}" STREQUAL "${expected}" OR NOT output MATCHES "${regex}")
message(FATAL_ERROR "${ARGN}: exit=${result}, stdout=${output}, stderr=${error}")
endif()
endfunction()
check("${APP}" 0 "\"schema\": 1.*\"models\"|\"models\".*\"schema\": 1" --list-models --json)
check("${APP}" 1 "\"state\": \"not_installed\"" --check-model redux --model-dir "${STAGE}/absent" --json)
check("${APP}" 2 "^$" --check-model "redux;echo" --model-dir "${STAGE}/absent" --json)
check("${APP}" 2 "^$" --check-model "../redux" --model-dir "${STAGE}/absent" --json)
# Minimal installed payload, independent of the source tree's script/module/asset paths.
file(MAKE_DIRECTORY "${STAGE}/bin" "${STAGE}/scripts" "${STAGE}/python/frameyap" "${STAGE}/assets/backends")
file(COPY "${APP}" DESTINATION "${STAGE}/bin")
file(COPY "${ROOT}/scripts/model-status.py" DESTINATION "${STAGE}/scripts")
file(GLOB modules "${ROOT}/python/frameyap/*.py")
file(COPY ${modules} DESTINATION "${STAGE}/python/frameyap")
file(COPY "${ROOT}/assets/backends/redux.json" DESTINATION "${STAGE}/assets/backends")
get_filename_component(name "${APP}" NAME)
check("${STAGE}/bin/${name}" 0 "redux.*unknown" --list-models)
check("${STAGE}/bin/${name}" 1 "\"reason\": \"directory_missing\"" --check-model redux --model-dir "${STAGE}/absent" --json)
# Tiny local pinned fixture exercises real hash verification through the CLI.
file(MAKE_DIRECTORY "${STAGE}/fixtures" "${STAGE}/models/toy")
file(WRITE "${STAGE}/models/toy/weights.bin" "hello")
file(SHA256 "${STAGE}/models/toy/weights.bin" pinned_hash)
file(WRITE "${STAGE}/fixtures/toy.json" "{\n"
"\"schema\":1,\"id\":\"toy\",\"display_name\":\"Tiny fixture\",\n"
"\"launcher\":{\"type\":\"python\",\"path\":\"python/frameyap/worker.py\","
"\"arguments\":[\"{model_dir}\",\"{clip_dir}\"],\"protocol\":\"frameyap-worker-v1\"},\n"
"\"model\":{\"source\":\"https://example.invalid/toy\",\"revision\":\"fixture\","
"\"files\":[{\"path\":\"weights.bin\",\"size\":5,\"sha256\":\"${pinned_hash}\"}]},\n"
"\"attribution\":\"fixture only\",\"license\":{\"id\":\"MIT\",\"text\":\"fixture only\"},"
"\"requirements\":{\"cpu\":\"fixture only\",\"gpu\":\"none\"}}\n")
check("${STAGE}/bin/${name}" 0 "\"state\": \"installed_verified\""
--check-model toy --manifest-dir "${STAGE}/fixtures" --model-dir "${STAGE}/models/toy" --json)
check("${STAGE}/bin/${name}" 0 "\"state\": \"installed_verified\""
--list-models --manifest-dir "${STAGE}/fixtures" --model-dir "${STAGE}/models" --json)
file(WRITE "${STAGE}/models/toy/weights.bin" "HELLO")
check("${STAGE}/bin/${name}" 1 "\"reason\": \"hash_mismatch\""
--check-model toy --manifest-dir "${STAGE}/fixtures" --model-dir "${STAGE}/models/toy" --json)
+168
View File
@@ -0,0 +1,168 @@
#include "paced_delivery.hpp"
#include <cassert>
#include <chrono>
#include <stdexcept>
#include <string>
#include <vector>
using namespace frameyap;
using namespace std::chrono_literals;
namespace {
struct Fake {
using Time = std::chrono::steady_clock::time_point;
Time now{};
std::vector<std::string> events;
std::vector<Time> times;
bool focus = true, fail_acquire = false, fail_text = false, fail_enter = false;
bool lose_on_acquire = false;
int active = 0, acquisitions = 0, releases = 0;
struct Lease : DeliveryLease {
Fake& f;
explicit Lease(Fake& value) : f(value) { assert(f.active++ == 0); }
~Lease() override { --f.active; }
void text(const std::string& text) override {
f.events.push_back(text); f.times.push_back(f.now);
if (f.fail_text) throw std::runtime_error("uncertain text");
}
void enter() override {
f.events.push_back("<Enter>"); f.times.push_back(f.now);
if (f.fail_enter) throw std::runtime_error("uncertain Enter");
}
};
DeliveryFactory acquire() {
return [this]() -> std::unique_ptr<DeliveryLease> {
++acquisitions;
if (fail_acquire) throw std::runtime_error("unavailable");
auto lease = std::make_unique<Lease>(*this);
if (lose_on_acquire) focus = false;
return lease;
};
}
PacedDelivery make() {
return PacedDelivery(acquire(), [this] { return now; }, [this] { ++releases; });
}
};
void drain(Fake& f, PacedDelivery& p) {
for (int n = 0; p.active() && n < 300; ++n) {
p.tick(); f.now += 150ms;
}
assert(!p.active());
}
void bounds_and_gap() {
Fake f; auto p = f.make();
const std::string ascii(4096, 'a');
int begun = 0;
p.start(ascii, true, [&] { return f.focus; }, [&] { ++begun; });
assert(f.events.empty() && begun == 0); // start never commits
auto outcome = p.tick();
assert(!outcome && begun == 1 && f.events == std::vector<std::string>{std::string(24, 'a')});
f.now += 149ms; p.tick(); assert(f.events.size() == 1);
f.now += 1ms; drain(f, p);
assert(f.events.size() == 172); // ceil(4096/24) text commits plus Enter
assert(f.events.back() == "<Enter>");
std::string joined;
for (size_t i = 0; i + 1 < f.events.size(); ++i) {
assert(f.events[i].size() <= 24);
joined += f.events[i];
}
assert(joined == ascii && begun == 1);
// Another explicit action must respect the previous task's final commit.
f.now -= 100ms;
p.start("next", false, [&] { return f.focus; }, [] {});
p.tick(); assert(f.events.size() == 172);
f.now += 99ms; p.tick(); assert(f.events.size() == 172);
f.now += 1ms; p.tick(); assert(f.events.back() == "next");
for (size_t i = 1; i < f.times.size(); ++i) assert(f.times[i] - f.times[i-1] >= 150ms);
assert(f.active == 0);
f.now += 150ms; p.tick(); assert(f.releases == 1);
p.tick(); assert(f.releases == 1);
// 81 repeated ASCII codepoints exceed the keycode rotating-table window:
// four 24-point messages, never a single oversized commit.
p.start(std::string(81, 'x'), false, [&] { return f.focus; }, [] {});
auto before = f.events.size(); drain(f, p);
assert(f.events.size() == before + 4);
assert(f.events[before].size() == 24 && f.events.back().size() == 9);
}
void unicode_and_partial() {
Fake f; auto p = f.make();
const std::string mixed = "界😀éa";
std::string payload;
for (int n = 0; n < 30; ++n) payload += mixed;
p.start(payload, true, [&] { return f.focus; }, [] {});
drain(f, p);
std::string joined;
for (const auto& batch : f.events) if (batch != "<Enter>") {
assert(literal_text(batch) == batch);
joined += batch;
}
assert(joined == payload && f.events.size() == 6 && f.events.back() == "<Enter>");
f.now += 150ms;
p.start("lost before", true, [&] { return f.focus; }, [] {});
f.focus = false;
auto before = f.events.size();
auto blocked = p.tick();
assert(blocked && !blocked->began && blocked->focus_lost && f.events.size() == before);
f.focus = true;
p.start(std::string(80, 'z'), true, [&] { return f.focus; }, [] {});
p.tick(); assert(f.events.size() == before + 1);
f.focus = false; f.now += 150ms;
auto partial = p.tick();
assert(partial && partial->began && partial->focus_lost && f.events.size() == before + 1);
assert(f.events.back() != "<Enter>");
f.focus = true; f.now += 150ms;
p.start("text then maybe Enter", true, [&] { return f.focus; }, [] {});
p.tick();
f.focus = false; f.now += 150ms;
partial = p.tick();
assert(partial && partial->began && partial->focus_lost);
assert(f.events.back() == "text then maybe Enter" && !p.active());
}
void permit_revocation() {
Fake f; bool permitted = true;
PacedDelivery p(f.acquire(), [&] { return f.now; }, {}, [&] { return permitted; });
int consumed = 0;
p.start("review", true, [&] { return f.focus; }, [&] { ++consumed; });
permitted = false;
auto stopped = p.tick();
assert(stopped && !stopped->began && stopped->focus_lost && consumed == 0 && f.events.empty());
permitted = true;
p.start(std::string(81, 'p'), true, [&] { return f.focus; }, [&] { ++consumed; });
p.tick(); assert(consumed == 1 && f.events.size() == 1);
permitted = false; f.now += 150ms;
stopped = p.tick();
assert(stopped && stopped->began && f.events.size() == 1 && !p.active());
}
void refusal_cancel_uncertain() {
Fake f; auto p = f.make();
int consumed = 0;
f.lose_on_acquire = true;
try { p.start("review", false, [&] { return f.focus; }, [&] { ++consumed; }); assert(false); }
catch (const std::runtime_error&) {}
assert(!p.active() && consumed == 0 && f.events.empty() && f.active == 0);
f.lose_on_acquire = false; f.focus = true; f.fail_acquire = true;
try { p.start("review", true, [&] { return f.focus; }, [&] { ++consumed; }); assert(false); }
catch (const std::runtime_error&) {}
assert(!p.active() && consumed == 0 && f.events.empty());
f.fail_acquire = false;
p.start("cancelled before first", true, [&] { return f.focus; }, [&] { ++consumed; });
p.cancel(); p.tick(); assert(consumed == 0 && f.events.empty() && f.active == 0);
p.start(std::string(100, 'a'), true, [&] { return f.focus; }, [&] { ++consumed; });
p.tick(); assert(consumed == 1 && f.events.size() == 1);
p.cancel(); f.now += 300ms; p.tick(); assert(f.events.size() == 1 && f.releases == 2);
f.fail_text = true;
p.start("uncertain", true, [&] { return f.focus; }, [&] { ++consumed; });
auto out = p.tick();
assert(out && out->began && out->result == DeliveryResult::TextUncertain);
assert(f.events.size() == 2 && consumed == 2);
f.fail_text = false; f.now += 150ms;
p.start("ok", true, [&] { return f.focus; }, [] {});
p.tick(); f.now += 150ms; f.fail_enter = true;
out = p.tick(); assert(out && out->result == DeliveryResult::EnterUncertain);
assert(f.events.back() == "<Enter>");
f.now += 150ms; f.fail_enter = false;
p.start("body", true, [&] { return f.focus; }, [] {});
p.tick(); f.now += 150ms; f.fail_acquire = true;
out = p.tick(); assert(out && out->result == DeliveryResult::TextQueuedEnterUnavailable);
assert(f.events.back() == "body" && f.active == 0);
}
} // namespace
int main() { bounds_and_gap(); unicode_and_partial(); permit_revocation(); refusal_cancel_uncertain(); }
+153 -2
View File
@@ -1,4 +1,5 @@
#include "core.hpp"
#include "paced_delivery.hpp"
#include "text_input.hpp"
#include "gamescope-input-method-server.h"
#include <wayland-server.h>
@@ -69,6 +70,7 @@ struct Snapshot {
int creates = 0, destroyed = 0;
bool bad_seat = false;
std::vector<Event> events;
std::vector<std::string> pending, delivered;
};
class FakeServer {
public:
@@ -109,6 +111,13 @@ public:
std::unique_lock lock(mutex_);
return changed_.wait_for(lock, 1s, [&] { return state_.destroyed >= count; });
}
// Model asynchronous target delivery separately from IME request processing.
// A Wayland sync can see commit before the destination drains keys.
void drain_target() {
std::lock_guard lock(mutex_);
state_.delivered.insert(state_.delivered.end(), state_.pending.begin(), state_.pending.end());
state_.pending.clear();
}
static constexpr const char* socket = "frameyap-fake";
private:
struct DisplayDeleter { void operator()(wl_display* p) const { if (p) wl_display_destroy(p); } };
@@ -159,6 +168,7 @@ private:
{
std::lock_guard lock(server.mutex_);
++server.state_.destroyed;
server.state_.pending.clear(); // A teardown may invalidate undrained keys/keymap.
}
server.changed_.notify_all();
}
@@ -166,6 +176,11 @@ private:
auto& server = owner(r);
std::lock_guard lock(server.mutex_);
server.state_.events.push_back({"commit", "", serial});
if (server.state_.events.size() >= 2) {
const auto& previous = server.state_.events[server.state_.events.size() - 2];
if (previous.kind == "string") server.state_.pending.push_back(previous.text);
if (previous.kind == "action") server.state_.pending.push_back("<Enter>");
}
}
static void set_string(wl_client*, wl_resource* r, const char* text) {
auto& server = owner(r);
@@ -185,16 +200,20 @@ void test_text() {
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");
input.text("Hello 世界 😀\r\nnext");
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[0].text == "Hello 世界 😀 next");
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.snapshot().destroyed == 0); // sync != target delivery
server.drain_target();
CHECK((server.snapshot().delivered == std::vector<std::string>{"Hello 世界 😀 next"}));
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().events.size() == 2);
}
@@ -213,6 +232,10 @@ void test_enter() {
throws([&] { input.text("duplicate"); });
CHECK(server.snapshot().events.size() == 2);
}
CHECK(server.snapshot().destroyed == 0);
server.drain_target();
CHECK((server.snapshot().delivered == std::vector<std::string>{"<Enter>"}));
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().events.size() == 2);
}
@@ -229,6 +252,7 @@ void test_invalid() {
throws([&] { input.text("\xe2\x82"); });
throws([&] { input.text("\xc2\x85"); });
throws([&] { input.text(std::string(4097, 'a')); });
throws([&] { input.text(std::string(25, 'a')); }); // server keymap, not Wayland bytes
throws([&] { input.text("\t\n"); });
CHECK(server.snapshot().events.empty());
input.text("submit; $(echo x)");
@@ -236,6 +260,7 @@ void test_invalid() {
CHECK(events.size() == 2 && events[0].text == "submit; $(echo x)");
CHECK(events[1].kind == "commit");
}
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
}
@@ -259,9 +284,133 @@ void test_review_insert() {
CHECK(events[0].text == "send 世界");
CHECK(events[1].kind == "commit" && events[1].number == 42);
}
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
}
// A fake target queue intentionally decouples compositor commit from target
// delivery. This regression catches an IME close/recreate between successive
// sends; it does not claim that a real application's input was acknowledged.
void test_repeated_full_payloads() {
FakeServer server;
struct Lease : DeliveryLease {
explicit Lease(const char* socket) : input(socket) {}
void text(const std::string& s) override { input.text(s); }
void enter() override { input.enter(); }
TextInput input;
};
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced([&]() -> std::unique_ptr<DeliveryLease> {
return std::make_unique<Lease>(server.socket);
}, [&] { return now; });
auto send = [&](std::string payload, bool enter) {
paced.start(std::move(payload), enter, [] { return true; }, [] {});
int commits = 0;
while (paced.active()) {
auto before = server.snapshot().pending.size();
paced.tick();
auto after = server.snapshot().pending.size();
CHECK(after == before || after == before + 1);
if (after != before) {
++commits;
now += 150ms;
} else now += 150ms;
}
return commits;
};
CHECK(send("first submission ", true) == 2);
CHECK(server.snapshot().creates == 1 && server.snapshot().destroyed == 0);
throws([&] { TextInput wrong("another-socket"); });
// Reproduce six retained-IME, 81-ASCII actions as protocol requests.
// This fake cannot emulate Gamescope's rotating keycode implementation.
for (int repeat = 0; repeat < 6; ++repeat)
CHECK(send(std::string(81, 'r'), false) == 4);
CHECK(server.snapshot().creates == 1 && server.snapshot().destroyed == 0);
auto repeated = server.snapshot().pending;
CHECK(repeated.size() == 26);
for (size_t i = 2; i < repeated.size(); i += 4)
CHECK(repeated[i] + repeated[i+1] + repeated[i+2] + repeated[i+3] == std::string(81, 'r'));
// A long UTF-8 literal stays intact while each protocol message uses a
// bounded number of codepoints, not the previous 4080-byte chunk size.
std::string bounded = "A";
for (int i = 0; i < 1363; ++i) bounded += "界";
bounded += "end!!!";
CHECK(bounded.size() == 4096);
auto count = send(bounded, false);
CHECK(count == 58); // ceil(1370 / 24)
auto pending = server.snapshot().pending;
CHECK(pending.size() == 26 + static_cast<size_t>(count));
CHECK(pending[0] == "first submission " && pending[1] == "<Enter>");
std::string joined;
for (size_t i = 26; i < pending.size(); ++i) {
CHECK(literal_text(pending[i]) == pending[i]);
size_t points = 0;
for (unsigned char ch : pending[i]) if ((ch & 0xc0) != 0x80) ++points;
CHECK(points <= 24);
joined += pending[i];
}
CHECK(joined == bounded);
server.drain_target();
CHECK(server.snapshot().delivered == pending);
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().pending.empty());
}
void test_space_batches() {
FakeServer server;
struct Lease : DeliveryLease {
explicit Lease(const char* socket) : input(socket) {}
void text(const std::string& s) override { input.text(s); }
void enter() override { input.enter(); }
TextInput input;
};
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced([&]() -> std::unique_ptr<DeliveryLease> {
return std::make_unique<Lease>(server.socket);
}, [&] { return now; });
for (const auto& literal : {std::string(24, ' ') + "tail", std::string(24, 'X') + std::string(24, ' ')}) {
const auto first = server.snapshot().pending.size();
paced.start(literal, false, [] { return true; }, [] {});
std::optional<PacedDelivery::Outcome> outcome;
while (paced.active()) { outcome = paced.tick(); now += 150ms; }
CHECK(outcome && outcome->result == DeliveryResult::TextQueued);
const auto pending = server.snapshot().pending;
std::string joined;
for (size_t i = first; i < pending.size(); ++i) joined += pending[i];
CHECK(joined == literal);
}
CHECK(literal_text(std::string(24, ' ')).empty()); // whole-ASR policy unchanged
server.drain_target();
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
}
void test_discovery_does_not_hold_ime() {
FakeServer server;
{
TextInput input(server.socket);
CHECK(server.snapshot().creates == 1 && server.snapshot().events.empty());
}
CHECK(server.wait_destroyed(1)); // no input sent: allow other seat IMEs
TextInput::release_idle();
}
void test_release_not_ack() {
FakeServer server;
const auto before = std::chrono::steady_clock::now();
{
TextInput input(server.socket);
throws([&] { TextInput::release_idle(); }); // active lease is exclusive
input.text("undrained");
}
CHECK(server.snapshot().pending == std::vector<std::string>({"undrained"}));
TextInput::release_idle(); // immediate Quit still honors the remaining cooldown
CHECK(std::chrono::steady_clock::now() - before >= 100ms);
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().delivered.empty()); // fixture models possible loss
}
void test_unavailable() {
FakeServer server(FakeServer::Mode::Unavailable);
throws([&] { TextInput input(server.socket); });
@@ -282,6 +431,8 @@ void test_stalled() {
int main() {
try {
test_text(); test_enter(); test_invalid(); test_review_insert();
test_repeated_full_payloads(); test_space_batches();
test_discovery_does_not_hold_ime(); test_release_not_ack();
test_unavailable(); test_stalled();
std::cout << "native fake Wayland input checks passed\n";
} catch (const std::exception& e) {