mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 03:00:04 +02:00
Wire native OpenVR voice-typing POC with safe review and grip controls
This commit is contained in:
1 parent
73dc4fda06
commit
d60d8d2f5d
21 files changed
+1929
-29
No files matched your search
+3
-3
@@ -22,14 +22,14 @@ foreach(mode IN ITEMS default help version invalid extra)
|
||||
message(FATAL_ERROR "${mode}: exit ${result}, expected ${expected_exit}: ${error}")
|
||||
endif()
|
||||
if(mode STREQUAL "version")
|
||||
if(NOT output STREQUAL "frame-dictation ${EXPECTED_VERSION} (scaffold)\n")
|
||||
if(NOT output STREQUAL "frameyap ${EXPECTED_VERSION}\n")
|
||||
message(FATAL_ERROR "Unexpected version: ${output}")
|
||||
endif()
|
||||
elseif(expected_exit EQUAL 2)
|
||||
if(NOT error MATCHES "Unsupported arguments")
|
||||
message(FATAL_ERROR "Missing rejection diagnostic")
|
||||
endif()
|
||||
elseif(NOT output MATCHES "Scaffold only")
|
||||
message(FATAL_ERROR "Missing scaffold status")
|
||||
elseif(NOT output MATCHES "No device access unless")
|
||||
message(FATAL_ERROR "Missing explicit runtime opt-in status")
|
||||
endif()
|
||||
endforeach()
|
||||
@@ -0,0 +1,73 @@
|
||||
#include "gestures.hpp"
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
using namespace frameyap;
|
||||
using namespace std::chrono_literals;
|
||||
#define CHECK(x) do { if (!(x)) { std::cerr << __FILE__ << ':' << __LINE__ << " CHECK failed: " #x << '\n'; std::exit(1); } } while (false)
|
||||
int main() {
|
||||
using Clock = DoubleTap::Clock;
|
||||
const auto t = Clock::time_point{};
|
||||
DoubleTap left;
|
||||
auto l = [&](bool active, bool down, bool enabled, int ms) {
|
||||
return left.update(active, down, enabled, t + std::chrono::milliseconds(ms));
|
||||
};
|
||||
CHECK(!l(true, true, true, 0)); // held on reconnect must rearm
|
||||
CHECK(!l(true, false, true, 10));
|
||||
CHECK(!l(true, true, true, 20));
|
||||
CHECK(!l(true, false, true, 270)); // inclusive 250ms
|
||||
CHECK(!l(true, true, true, 620)); // inclusive 350ms
|
||||
CHECK(l(true, false, true, 870));
|
||||
CHECK(!l(true, true, true, 880));
|
||||
CHECK(!l(true, false, true, 890));
|
||||
CHECK(!l(true, true, true, 1241)); // late second press
|
||||
CHECK(!l(true, false, true, 1250));
|
||||
CHECK(!l(true, true, true, 1260));
|
||||
CHECK(!l(true, false, true, 1511)); // long second press: no Enter
|
||||
CHECK(!l(false, false, true, 1520));
|
||||
CHECK(!l(true, true, true, 1530));
|
||||
CHECK(!l(true, false, true, 1540));
|
||||
CHECK(!l(true, true, false, 1550));
|
||||
CHECK(!l(true, false, true, 1560));
|
||||
|
||||
using R = GripRecord::Change;
|
||||
GripRecord right;
|
||||
auto r = [&](bool active, bool down, int ms) {
|
||||
return right.update(active, down, t + std::chrono::milliseconds(ms));
|
||||
};
|
||||
CHECK(r(true, true, 0) == R::None);
|
||||
CHECK(r(true, false, 10) == R::None);
|
||||
CHECK(r(true, true, 20) == R::None);
|
||||
CHECK(r(true, false, 270) == R::None);
|
||||
CHECK(r(true, true, 620) == R::Begin);
|
||||
CHECK(r(true, true, 99999) == R::None); // no accidental Enter or stop on long hold
|
||||
CHECK(r(true, false, 100000) == R::End);
|
||||
CHECK(r(true, true, 100010) == R::None);
|
||||
CHECK(r(true, false, 100011) == R::None);
|
||||
CHECK(r(true, true, 100362) == R::None);
|
||||
CHECK(r(true, false, 100370) == R::None);
|
||||
CHECK(r(true, true, 100380) == R::Begin);
|
||||
CHECK(r(false, true, 100381) == R::Cancel); // lost pose/activity while held
|
||||
CHECK(r(true, true, 100382) == R::None); // reconnect held cannot finish/start
|
||||
CHECK(r(true, false, 100383) == R::None);
|
||||
CHECK(r(true, true, 100390) == R::None);
|
||||
CHECK(r(true, false, 100641) == R::None); // first squeeze too long
|
||||
CHECK(r(true, true, 100650) == R::None);
|
||||
CHECK(r(true, false, 100660) == R::None);
|
||||
CHECK(r(true, true, 101010) == R::Begin);
|
||||
CHECK(right.reset() == R::Cancel); // overlay focus lost
|
||||
CHECK(right.reset() == R::None);
|
||||
CHECK(r(true, true, 101020) == R::None);
|
||||
CHECK(r(true, false, 101030) == R::None);
|
||||
|
||||
NeutralEdge e;
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, false) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::Down);
|
||||
CHECK(e.reset()); // PTT activity/tracking/focus loss cancels capture
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, false) == NeutralEdge::Change::None);
|
||||
CHECK(e.update(true, true) == NeutralEdge::Change::Down);
|
||||
CHECK(e.update(true, false) == NeutralEdge::Change::Up);
|
||||
CHECK(!e.reset());
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#include "instance_lock.hpp"
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
int main() {
|
||||
char pattern[] = "/tmp/frameyap-lock-test-XXXXXX";
|
||||
char* dir = mkdtemp(pattern);
|
||||
if (!dir) return 1;
|
||||
setenv("FRAMEYAP_INSTALL_ROOT", dir, 1);
|
||||
try {
|
||||
auto first = std::make_unique<frameyap::InstanceLock>();
|
||||
bool rejected = false;
|
||||
try { frameyap::InstanceLock second; } catch (const std::runtime_error&) { rejected = true; }
|
||||
if (!rejected) throw std::runtime_error("second instance acquired lock");
|
||||
first.reset();
|
||||
{ frameyap::InstanceLock next; }
|
||||
auto lock = std::filesystem::path(dir) / ".lock";
|
||||
std::filesystem::remove(lock);
|
||||
std::filesystem::create_symlink("foreign", lock);
|
||||
rejected = false;
|
||||
try { frameyap::InstanceLock unsafe; } catch (const std::runtime_error&) { rejected = true; }
|
||||
if (!rejected) throw std::runtime_error("followed unsafe lock symlink");
|
||||
std::filesystem::remove(lock);
|
||||
std::filesystem::remove(dir);
|
||||
std::cout << "instance lock checks passed\n";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
std::filesystem::remove_all(dir);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#include "core.hpp"
|
||||
#include "text_input.hpp"
|
||||
#include "gamescope-input-method-server.h"
|
||||
#include <wayland-server.h>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
using namespace frameyap;
|
||||
using namespace std::chrono_literals;
|
||||
#define CHECK(condition) do { if (!(condition)) throw std::runtime_error( \
|
||||
std::string("line ") + std::to_string(__LINE__) + ": " #condition); } while (false)
|
||||
|
||||
template <typename F> void throws(F&& f) {
|
||||
try { f(); } catch (const std::runtime_error&) { return; }
|
||||
CHECK(false);
|
||||
}
|
||||
|
||||
// This fixture never connects to the ambient compositor: the client receives only
|
||||
// this private socket name, scoped to a freshly-created 0700 runtime directory.
|
||||
class RuntimeDir {
|
||||
public:
|
||||
RuntimeDir() {
|
||||
if (const char* old = std::getenv("XDG_RUNTIME_DIR")) { prior_ = old; had_prior_ = true; }
|
||||
char pattern[] = "/tmp/frameyap-wayland-test-XXXXXX";
|
||||
char* created = mkdtemp(pattern);
|
||||
if (!created) throw std::runtime_error("mkdtemp failed");
|
||||
path_ = created;
|
||||
struct stat st{};
|
||||
if (stat(path_.c_str(), &st) != 0 || (st.st_mode & 0777) != 0700) {
|
||||
rmdir(path_.c_str());
|
||||
throw std::runtime_error("runtime directory is not private");
|
||||
}
|
||||
if (setenv("XDG_RUNTIME_DIR", path_.c_str(), 1)) {
|
||||
rmdir(path_.c_str());
|
||||
throw std::runtime_error("setenv failed");
|
||||
}
|
||||
}
|
||||
~RuntimeDir() {
|
||||
if (had_prior_) setenv("XDG_RUNTIME_DIR", prior_.c_str(), 1);
|
||||
else unsetenv("XDG_RUNTIME_DIR");
|
||||
unlink((path_ + "/frameyap-fake").c_str());
|
||||
unlink((path_ + "/frameyap-fake.lock").c_str());
|
||||
rmdir(path_.c_str());
|
||||
}
|
||||
RuntimeDir(const RuntimeDir&) = delete;
|
||||
RuntimeDir& operator=(const RuntimeDir&) = delete;
|
||||
private:
|
||||
std::string path_, prior_;
|
||||
bool had_prior_ = false;
|
||||
};
|
||||
|
||||
struct Event {
|
||||
std::string kind, text;
|
||||
uint32_t number = 0;
|
||||
};
|
||||
struct Snapshot {
|
||||
int creates = 0, destroyed = 0;
|
||||
bool bad_seat = false;
|
||||
std::vector<Event> events;
|
||||
};
|
||||
class FakeServer {
|
||||
public:
|
||||
enum class Mode { Ready, Unavailable, Stalled };
|
||||
explicit FakeServer(Mode mode = Mode::Ready) : mode_(mode) {
|
||||
display_.reset(wl_display_create());
|
||||
CHECK(display_ != nullptr);
|
||||
CHECK(wl_display_add_socket(display_.get(), "frameyap-fake") == 0);
|
||||
CHECK(wl_global_create(display_.get(), &wl_seat_interface, 1, this, bind_seat));
|
||||
CHECK(wl_global_create(display_.get(), &gamescope_input_method_manager_interface,
|
||||
2, this, bind_manager));
|
||||
thread_ = std::thread([this] {
|
||||
if (mode_ == Mode::Stalled) {
|
||||
while (!stop_) std::this_thread::sleep_for(5ms);
|
||||
return;
|
||||
}
|
||||
while (!stop_) {
|
||||
wl_event_loop_dispatch(wl_display_get_event_loop(display_.get()), 10);
|
||||
wl_display_flush_clients(display_.get());
|
||||
}
|
||||
});
|
||||
}
|
||||
~FakeServer() {
|
||||
stop_ = true;
|
||||
if (thread_.joinable()) thread_.join();
|
||||
// Destroy client resources while callback state/mutex are still alive,
|
||||
// including on a test assertion failure.
|
||||
wl_display_destroy_clients(display_.get());
|
||||
display_.reset();
|
||||
}
|
||||
FakeServer(const FakeServer&) = delete;
|
||||
FakeServer& operator=(const FakeServer&) = delete;
|
||||
Snapshot snapshot() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return state_;
|
||||
}
|
||||
bool wait_destroyed(int count) {
|
||||
std::unique_lock lock(mutex_);
|
||||
return changed_.wait_for(lock, 1s, [&] { return state_.destroyed >= count; });
|
||||
}
|
||||
static constexpr const char* socket = "frameyap-fake";
|
||||
private:
|
||||
struct DisplayDeleter { void operator()(wl_display* p) const { if (p) wl_display_destroy(p); } };
|
||||
RuntimeDir runtime_;
|
||||
std::unique_ptr<wl_display, DisplayDeleter> display_;
|
||||
Mode mode_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread thread_;
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable changed_;
|
||||
Snapshot state_;
|
||||
|
||||
static FakeServer& owner(wl_resource* r) {
|
||||
return *static_cast<FakeServer*>(wl_resource_get_user_data(r));
|
||||
}
|
||||
static void bind_seat(wl_client* client, void* data, uint32_t version, uint32_t id) {
|
||||
auto* r = wl_resource_create(client, &wl_seat_interface, version, id);
|
||||
if (!r) { wl_client_post_no_memory(client); return; }
|
||||
wl_resource_set_implementation(r, nullptr, data, nullptr);
|
||||
}
|
||||
static void bind_manager(wl_client* client, void* data, uint32_t version, uint32_t id) {
|
||||
auto* r = wl_resource_create(client, &gamescope_input_method_manager_interface, version, id);
|
||||
if (!r) { wl_client_post_no_memory(client); return; }
|
||||
static const struct gamescope_input_method_manager_interface methods{destroy, create};
|
||||
wl_resource_set_implementation(r, &methods, data, nullptr);
|
||||
}
|
||||
static void destroy(wl_client*, wl_resource* r) { wl_resource_destroy(r); }
|
||||
static void create(wl_client* client, wl_resource* manager, wl_resource* seat, uint32_t id) {
|
||||
auto& server = owner(manager);
|
||||
auto* r = wl_resource_create(client, &gamescope_input_method_interface,
|
||||
wl_resource_get_version(manager), id);
|
||||
if (!r) { wl_client_post_no_memory(client); return; }
|
||||
static const struct gamescope_input_method_interface methods{
|
||||
destroy, commit, set_string, set_action, nullptr, nullptr, nullptr, nullptr};
|
||||
wl_resource_set_implementation(r, &methods, &server, ime_destroyed);
|
||||
{
|
||||
std::lock_guard lock(server.mutex_);
|
||||
++server.state_.creates;
|
||||
if (!seat || wl_resource_get_client(seat) != client ||
|
||||
std::strcmp(wl_resource_get_class(seat), "wl_seat") != 0 ||
|
||||
wl_resource_get_version(manager) != 2) server.state_.bad_seat = true;
|
||||
}
|
||||
if (server.mode_ == Mode::Ready) gamescope_input_method_send_done(r, 42);
|
||||
else gamescope_input_method_send_unavailable(r);
|
||||
}
|
||||
static void ime_destroyed(wl_resource* r) {
|
||||
auto& server = owner(r);
|
||||
{
|
||||
std::lock_guard lock(server.mutex_);
|
||||
++server.state_.destroyed;
|
||||
}
|
||||
server.changed_.notify_all();
|
||||
}
|
||||
static void commit(wl_client*, wl_resource* r, uint32_t serial) {
|
||||
auto& server = owner(r);
|
||||
std::lock_guard lock(server.mutex_);
|
||||
server.state_.events.push_back({"commit", "", serial});
|
||||
}
|
||||
static void set_string(wl_client*, wl_resource* r, const char* text) {
|
||||
auto& server = owner(r);
|
||||
std::lock_guard lock(server.mutex_);
|
||||
server.state_.events.push_back({"string", text, 0});
|
||||
}
|
||||
static void set_action(wl_client*, wl_resource* r, uint32_t action) {
|
||||
auto& server = owner(r);
|
||||
std::lock_guard lock(server.mutex_);
|
||||
server.state_.events.push_back({"action", "", action});
|
||||
}
|
||||
};
|
||||
|
||||
void test_text() {
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
auto initial = server.snapshot();
|
||||
CHECK(initial.creates == 1 && !initial.bad_seat && initial.events.empty());
|
||||
input.text("Hello 世界 😀\r\nnext\tline\xe2\x80\xa8 end");
|
||||
auto state = server.snapshot();
|
||||
CHECK(state.events.size() == 2);
|
||||
CHECK(state.events[0].kind == "string");
|
||||
CHECK(state.events[0].text == "Hello 世界 😀 next line end");
|
||||
CHECK(state.events[1].kind == "commit" && state.events[1].number == 42);
|
||||
// Authorization is consumed even when a duplicate delivery is attempted.
|
||||
throws([&] { input.enter(); });
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
|
||||
void test_enter() {
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
CHECK(server.snapshot().events.empty());
|
||||
input.enter();
|
||||
auto state = server.snapshot();
|
||||
CHECK(state.events.size() == 2);
|
||||
CHECK(state.events[0].kind == "action" &&
|
||||
state.events[0].number == GAMESCOPE_INPUT_METHOD_ACTION_SUBMIT);
|
||||
CHECK(state.events[1].kind == "commit" && state.events[1].number == 42);
|
||||
throws([&] { input.text("duplicate"); });
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
CHECK(server.snapshot().events.size() == 2);
|
||||
}
|
||||
|
||||
void test_invalid() {
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
throws([&] { input.text(std::string("x\0y", 3)); });
|
||||
throws([&] { input.text("\x1b[31m"); });
|
||||
throws([&] { input.text("\xc0\xaf"); });
|
||||
throws([&] { input.text("\xed\xa0\x80"); });
|
||||
throws([&] { input.text("\xf4\x90\x80\x80"); });
|
||||
throws([&] { input.text("\xe2\x82"); });
|
||||
throws([&] { input.text("\xc2\x85"); });
|
||||
throws([&] { input.text(std::string(4097, 'a')); });
|
||||
throws([&] { input.text("\t\n"); });
|
||||
CHECK(server.snapshot().events.empty());
|
||||
input.text("submit; $(echo x)");
|
||||
auto events = server.snapshot().events;
|
||||
CHECK(events.size() == 2 && events[0].text == "submit; $(echo x)");
|
||||
CHECK(events[1].kind == "commit");
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
}
|
||||
|
||||
void test_review_insert() {
|
||||
Session session;
|
||||
session.ready();
|
||||
CHECK(session.record() && session.finish(16000));
|
||||
auto id = session.id();
|
||||
CHECK(session.reply(id, "send\n世界"));
|
||||
CHECK(session.state() == State::Review);
|
||||
FakeServer server;
|
||||
{
|
||||
TextInput input(server.socket);
|
||||
CHECK(server.snapshot().events.empty()); // Discovery is not insertion.
|
||||
auto approved = session.take_insert();
|
||||
CHECK(approved == "send 世界" && !session.take_insert());
|
||||
CHECK(!session.reply(id, "duplicate"));
|
||||
input.text(*approved);
|
||||
auto events = server.snapshot().events;
|
||||
CHECK(events.size() == 2 && events[0].kind == "string");
|
||||
CHECK(events[0].text == "send 世界");
|
||||
CHECK(events[1].kind == "commit" && events[1].number == 42);
|
||||
}
|
||||
CHECK(server.wait_destroyed(1));
|
||||
}
|
||||
|
||||
void test_unavailable() {
|
||||
FakeServer server(FakeServer::Mode::Unavailable);
|
||||
throws([&] { TextInput input(server.socket); });
|
||||
CHECK(server.wait_destroyed(1));
|
||||
auto state = server.snapshot();
|
||||
CHECK(state.creates == 1 && state.events.empty());
|
||||
}
|
||||
|
||||
void test_stalled() {
|
||||
FakeServer server(FakeServer::Mode::Stalled);
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
throws([&] { TextInput input(server.socket); });
|
||||
auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
CHECK(elapsed >= 700ms && elapsed < 4s);
|
||||
CHECK(server.snapshot().creates == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_text(); test_enter(); test_invalid(); test_review_insert();
|
||||
test_unavailable(); test_stalled();
|
||||
std::cout << "native fake Wayland input checks passed\n";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "text_input_test: " << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user