mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 04:04:07 +02:00
feat(runtime): integrate backend controls and guarded paced text delivery
This commit is contained in:
1 parent
1321ca8316
commit
9829d0611d
26 files changed
+2238
-399
No files matched your search
+73
-27
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}));
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user