diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ebdd68..5e6d3d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 /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=$" "-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=$" + "-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) diff --git a/src/check.cpp b/src/check.cpp new file mode 100644 index 0000000..a7f5895 --- /dev/null +++ b/src/check.cpp @@ -0,0 +1,74 @@ +#include "check.hpp" +#ifdef FRAMEYAP_NATIVE +#include "instance_lock.hpp" +#include "overlay.hpp" +#include "text_input.hpp" +#include +#include +#include +#include + +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, 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 diff --git a/src/check.hpp b/src/check.hpp new file mode 100644 index 0000000..4e12ca4 --- /dev/null +++ b/src/check.hpp @@ -0,0 +1,11 @@ +#pragma once +#include +#include +#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, bool controls); +} +#endif diff --git a/src/cli.cpp b/src/cli.cpp new file mode 100644 index 0000000..89c93bc --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,127 @@ +#include "cli.hpp" +#include +#include +#include + +namespace frameyap { +namespace { +using Mode = CliMode; +constexpr unsigned bit(Mode mode) { return 1u << static_cast(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 diff --git a/src/cli.hpp b/src/cli.hpp new file mode 100644 index 0000000..93147ad --- /dev/null +++ b/src/cli.hpp @@ -0,0 +1,21 @@ +#pragma once +#include +#include +#include + +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 diff --git a/src/controller.cpp b/src/controller.cpp new file mode 100644 index 0000000..911bb12 --- /dev/null +++ b/src/controller.cpp @@ -0,0 +1,373 @@ +#include "controller.hpp" +#include +#include +#include +#include + +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 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 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(std::move(focus)); +} +void Controller::queue_paced(std::string text, bool enter, bool review, + std::shared_ptr 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(std::move(armed_focus_)), true); + } else { + // Fake synchronous path retains its existing single-send contract. + const DeliveryFactory guarded = [&]() -> std::unique_ptr { + 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 diff --git a/src/controller.hpp b/src/controller.hpp new file mode 100644 index 0000000..694c621 --- /dev/null +++ b/src/controller.hpp @@ -0,0 +1,86 @@ +#pragma once +#include "core.hpp" +#include "paced_delivery.hpp" +#include "overlay.hpp" +#include "worker.hpp" +#include +#include +#include + +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 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& pcm) = 0; + virtual std::optional poll() = 0; + virtual void stop() = 0; +}; +class ControllerFocus { +public: + virtual ~ControllerFocus() = default; + virtual bool arm() = 0; + virtual bool valid() = 0; +}; +using FocusFactory = std::function()>; + +class Controller { +public: + Controller(ControllerAudio& audio, ControllerWorker& worker, + DeliveryFactory delivery, FocusFactory focus, + std::vector 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 focus, bool automatic = false); + std::shared_ptr 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 quick_inputs_; + Session session_; + std::unique_ptr 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 diff --git a/src/core.cpp b/src/core.cpp index 75dcc45..93c3da9 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -64,14 +64,11 @@ std::unique_ptr 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); } diff --git a/src/core.hpp b/src/core.hpp index 8f13753..5e564b0 100644 --- a/src/core.hpp +++ b/src/core.hpp @@ -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. diff --git a/src/main.cpp b/src/main.cpp index 7ded893..99a77e0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,26 +1,32 @@ -#include -#include -#include +#include "cli.hpp" +#include "check.hpp" +#include "model_status.hpp" #include +#include +#include #ifdef FRAMEYAP_NATIVE #include "runtime.hpp" -#include "overlay.hpp" -#include "text_input.hpp" #include "instance_lock.hpp" -#include -#include -#include +#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(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; + } } diff --git a/src/model_status.cpp b/src/model_status.cpp new file mode 100644 index 0000000..b2f2400 --- /dev/null +++ b/src/model_status.cpp @@ -0,0 +1,60 @@ +#include "model_status.hpp" +#include +#include +#include +#include +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#include +#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 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 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 diff --git a/src/model_status.hpp b/src/model_status.hpp new file mode 100644 index 0000000..54ccf73 --- /dev/null +++ b/src/model_status.hpp @@ -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); +} diff --git a/src/paced_delivery.cpp b/src/paced_delivery.cpp new file mode 100644 index 0000000..6c4da8c --- /dev/null +++ b/src/paced_delivery.cpp @@ -0,0 +1,114 @@ +#include "paced_delivery.hpp" +#include +#include +#include + +namespace frameyap { +PacedDelivery::PacedDelivery(DeliveryFactory acquire, Clock clock, + std::function 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 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(); + 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::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 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(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 diff --git a/src/paced_delivery.hpp b/src/paced_delivery.hpp new file mode 100644 index 0000000..dc48f42 --- /dev/null +++ b/src/paced_delivery.hpp @@ -0,0 +1,55 @@ +#pragma once +#include "core.hpp" +#include +#include +#include +#include +#include + +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; + using Guard = std::function; + 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 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 on_first_send); + std::optional 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 on_first_send; + std::unique_ptr first_lease; + }; + bool valid_guard() const; + Outcome finish(DeliveryResult result, bool focus_lost = false); + void release_if_idle(); + DeliveryFactory acquire_; + Clock clock_; + std::function release_idle_; + Guard permitted_; + std::unique_ptr task_; + std::optional last_commit_; + bool retained_ = false; +}; +} // namespace frameyap diff --git a/src/runtime.cpp b/src/runtime.cpp index f232b61..4c14b6e 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -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 #include -#include #include #include -#include -#include +#include +#include #include -#include -#include #include -#include 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 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& pcm) override { worker_.submit(id, pcm); } + std::optional 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 armed_focus; + NativeWorker worker(options); + NativeAudio audio; const DeliveryFactory acquire = [&]() -> std::unique_ptr { auto input = std::make_unique(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(); - 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 { - 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(); }, + 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 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("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 diff --git a/src/runtime.hpp b/src/runtime.hpp index 510a912..efc84da 100644 --- a/src/runtime.hpp +++ b/src/runtime.hpp @@ -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; // saved preference, or world on first launch }; diff --git a/src/text_input.cpp b/src/text_input.cpp index cb41ff5..a0d8008 100644 --- a/src/text_input.cpp +++ b/src/text_input.cpp @@ -1,5 +1,6 @@ #include "text_input.hpp" #include "core.hpp" +#include "paced_delivery.hpp" #include "gamescope-input-method-client.h" #include #include @@ -7,9 +8,11 @@ #include #include #include +#include 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& retained() { + static std::shared_ptr connection; + return connection; + } + static std::weak_ptr& active() { + static std::weak_ptr lease; + return lease; + } ~Impl() { // Local proxy destruction then socket close avoids a blocking flush on teardown. if (ime) wl_proxy_destroy(reinterpret_cast(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()) { +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(); 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() 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(); } diff --git a/src/text_input.hpp b/src/text_input.hpp index ec2cd07..767150f 100644 --- a/src/text_input.hpp +++ b/src/text_input.hpp @@ -2,18 +2,25 @@ #include #include 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_; + std::shared_ptr impl_; + bool used_ = false; }; } diff --git a/tests/cli.cmake b/tests/cli.cmake index 4fa6894..437c0ff 100644 --- a/tests/cli.cmake +++ b/tests/cli.cmake @@ -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() diff --git a/tests/cli_git_tag.py b/tests/cli_git_tag.py new file mode 100644 index 0000000..6365a0b --- /dev/null +++ b/tests/cli_git_tag.py @@ -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() diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp new file mode 100644 index 0000000..f693eb7 --- /dev/null +++ b/tests/cli_test.cpp @@ -0,0 +1,80 @@ +#include "cli.hpp" +#include +#include +#include +#include +using namespace frameyap; +namespace { +CliOptions parse(std::initializer_list args) { + std::vector argv{"frameyap"}; + argv.insert(argv.end(), args.begin(), args.end()); + return parse_cli(static_cast(argv.size()), argv.data()); +} +bool rejects(std::initializer_list 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{"--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 argv{"frameyap"}; + argv.insert(argv.end(), args.begin(), args.end()); + bool failed = false; + try { parse_cli(static_cast(argv.size()), argv.data()); } + catch (const CliError&) { failed = true; } + assert(failed); + } + assert(rejects({"--version", "extra"})); +} diff --git a/tests/controller_test.cpp b/tests/controller_test.cpp new file mode 100644 index 0000000..3a34266 --- /dev/null +++ b/tests/controller_test.cpp @@ -0,0 +1,377 @@ +#include "controller.hpp" +#include +#include +#include +#include +#include + +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 finish() override { ++finishes; timeout = false; return std::vector(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 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& pcm) override { + assert(loaded && pcm.size() == 3200); request = id; ++submissions; + } + std::optional 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 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(""); } + InputState& state; +}; +struct Fixture { + AudioFake audio; + WorkerFake worker; + FocusState focus; + InputState input; + DeliveryFactory acquire() { + return [this]() -> std::unique_ptr { + ++input.acquisitions; + if (input.lose_focus_on_acquire) focus.valid = false; + if (input.unavailable) throw std::runtime_error("input unavailable"); + return std::make_unique(input); + }; + } + Controller make(bool automatic = false, bool close_mic = false, PacedDelivery* paced = nullptr) { + return Controller(audio, worker, acquire(), + [this]() -> std::unique_ptr { return std::make_unique(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() == ""); + 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(); + } +} diff --git a/tests/core_test.cpp b/tests/core_test.cpp index 90d5d6e..e44fa7e 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -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({"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() { diff --git a/tests/model_status.cmake b/tests/model_status.cmake new file mode 100644 index 0000000..a3243ef --- /dev/null +++ b/tests/model_status.cmake @@ -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) diff --git a/tests/paced_delivery_test.cpp b/tests/paced_delivery_test.cpp new file mode 100644 index 0000000..68618f3 --- /dev/null +++ b/tests/paced_delivery_test.cpp @@ -0,0 +1,168 @@ +#include "paced_delivery.hpp" +#include +#include +#include +#include +#include +using namespace frameyap; +using namespace std::chrono_literals; +namespace { +struct Fake { + using Time = std::chrono::steady_clock::time_point; + Time now{}; + std::vector events; + std::vector