mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 01:00:04 +02:00
feat(ui): add consent-bound model chooser and microphone controls
This commit is contained in:
1 parent
665d003782
commit
4498ef5d04
12 files changed
+1025
-168
No files matched your search
+4
-4
@@ -18,12 +18,12 @@
|
||||
"localization": [{
|
||||
"language_tag": "en_US",
|
||||
"/actions/frameyap": "FrameYap controls",
|
||||
"/actions/frameyap/in/left_grip": "Double tap left grip: insert pending text + Enter (enabled only)",
|
||||
"/actions/frameyap/in/left_grip": "Double tap left grip: Type + Enter (enabled only)",
|
||||
"/actions/frameyap/in/right_grip": "Tap right grip briefly, then squeeze and hold to record; release to finish",
|
||||
"/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)",
|
||||
"/actions/frameyap/in/cancel": "Cancel recording or discard",
|
||||
"/actions/frameyap/in/insert": "Insert reviewed text + trailing space",
|
||||
"/actions/frameyap/in/enter": "Submit: pending text + Enter, or Enter alone",
|
||||
"/actions/frameyap/in/quick_chat": "Open quick chat / cycle selection"
|
||||
"/actions/frameyap/in/insert": "Type reviewed text + trailing space",
|
||||
"/actions/frameyap/in/enter": "Type + Enter: pending text + Enter, or Enter alone",
|
||||
"/actions/frameyap/in/quick_chat": "Open quick phrases / cycle selection"
|
||||
}]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ second manifest/JSON/hash implementation. Installer execution is opt-in ONLY.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
@@ -40,6 +41,13 @@ def manifest_sha(manifest_dir, ident):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def install_error(code, message):
|
||||
# stdout is the manager's bounded event channel; stderr is intentionally
|
||||
# closed there. Never claim success or start an installer on these paths.
|
||||
print(json.dumps({"ok": False, "code": code, "message": message}), flush=True)
|
||||
return 2
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
operation = parser.add_mutually_exclusive_group(required=True)
|
||||
@@ -87,15 +95,15 @@ def main(argv=None):
|
||||
# consent. The exec makes the installer the owned child (no hidden grandchild).
|
||||
if (not args.backend or not args.installer or not args.expected_manifest_sha256 or
|
||||
not _DIGEST.fullmatch(args.expected_manifest_sha256)):
|
||||
parser.error("backend, installer and 64-character consent fingerprint required")
|
||||
return install_error("usage", "backend, installer and consent fingerprint required")
|
||||
try:
|
||||
backends = load_backends(args.manifest_dir)
|
||||
if args.backend not in backends or manifest_sha(args.manifest_dir, args.backend) != args.expected_manifest_sha256:
|
||||
parser.error("selected backend manifest changed since consent")
|
||||
return install_error("manifest_mismatch", "selected backend manifest changed since consent")
|
||||
except (OSError, ValueError) as error:
|
||||
parser.error(f"selected backend manifest unavailable: {type(error).__name__}")
|
||||
return install_error("operation_failed", f"selected backend manifest unavailable: {type(error).__name__}")
|
||||
if not args.installer.is_file() or args.installer.is_symlink():
|
||||
parser.error("installer missing or unsafe")
|
||||
return install_error("operation_failed", "installer missing or unsafe")
|
||||
dest = args.model_store / args.backend
|
||||
os.execv("/bin/sh", ["sh", str(args.installer), "--install-model", "--backend", args.backend,
|
||||
"--model-dir", str(dest), "--expected-manifest-sha256",
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ inline bool normalize(Vec3& v) {
|
||||
} // namespace angle_fade_detail
|
||||
|
||||
// Wrist-only visibility: compare the entire panel orientation (including roll)
|
||||
// to an upright panel pointing at the viewer. Kouseki's watch fades linearly
|
||||
// between 60 and 75 degrees; the matrix math here has no engine dependency.
|
||||
// to an upright panel pointing at the viewer. Fade linearly between 60 and
|
||||
// 75 degrees; the matrix math here has no engine dependency.
|
||||
// Both poses must be in the same tracking space, with rigid orthonormal axes.
|
||||
// Invalid geometry hides the panel rather than making it clickable at full alpha.
|
||||
inline float wrist_view_opacity(const Matrix34& panel, const Matrix34& head) {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
#include "backend_manager.hpp"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <fcntl.h>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <regex>
|
||||
#include <spawn.h>
|
||||
#include <signal.h>
|
||||
#include <stdexcept>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
extern char** environ;
|
||||
|
||||
namespace frameyap {
|
||||
namespace {
|
||||
std::string decode(const std::string& value) {
|
||||
std::string output;
|
||||
for (size_t i = 0; i < value.size(); ++i) {
|
||||
if (value[i] == '%') {
|
||||
if (i + 2 >= value.size()) throw std::runtime_error("bad status encoding");
|
||||
unsigned n = 0;
|
||||
auto [end, error] = std::from_chars(value.data() + i + 1, value.data() + i + 3, n, 16);
|
||||
if (error != std::errc{} || end != value.data() + i + 3) throw std::runtime_error("bad status encoding");
|
||||
output += char(n); i += 2;
|
||||
} else output += value[i];
|
||||
}
|
||||
if (output.size() > 1100 || std::any_of(output.begin(), output.end(), [](unsigned char c) {
|
||||
return c < 32 || c == 127;
|
||||
})) throw std::runtime_error("unsafe status text");
|
||||
return output;
|
||||
}
|
||||
// The installed shell script emits one flat JSON object per line. Read only
|
||||
// known fields; malformed/unknown records have no UI or success authority.
|
||||
// Pipe byte and line caps are enforced by poll() before this parser runs.
|
||||
struct JsonValue { std::string text; bool quoted = false; };
|
||||
using JsonObject = std::map<std::string, JsonValue>;
|
||||
bool json_string(const std::string& s, size_t& pos, std::string& out) {
|
||||
if (pos >= s.size() || s[pos++] != '"') return false;
|
||||
while (pos < s.size()) {
|
||||
unsigned char c = static_cast<unsigned char>(s[pos++]);
|
||||
if (c == '"') return true;
|
||||
if (c < 32) return false;
|
||||
if (c != '\\') { out += char(c); continue; }
|
||||
if (pos == s.size()) return false;
|
||||
c = static_cast<unsigned char>(s[pos++]);
|
||||
switch (c) {
|
||||
case '"': case '\\': case '/': out += char(c); break;
|
||||
case 'b': case 'f': case 'n': case 'r': case 't': out += ' '; break;
|
||||
case 'u': {
|
||||
if (s.size() - pos < 4) return false;
|
||||
unsigned n = 0;
|
||||
auto [end, err] = std::from_chars(s.data() + pos, s.data() + pos + 4, n, 16);
|
||||
if (err != std::errc{} || end != s.data() + pos + 4) return false;
|
||||
pos += 4;
|
||||
out += n >= 32 && n < 127 ? char(n) : '?';
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::optional<JsonObject> json_event(const std::string& s) {
|
||||
size_t pos = 0;
|
||||
auto space = [&] { while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\t')) ++pos; };
|
||||
space();
|
||||
if (pos == s.size() || s[pos++] != '{') return {};
|
||||
JsonObject result;
|
||||
bool closed = false;
|
||||
space();
|
||||
if (pos < s.size() && s[pos] == '}') { ++pos; closed = true; }
|
||||
else while (pos < s.size() && result.size() < 16) {
|
||||
std::string key, value;
|
||||
if (!json_string(s, pos, key) || key.size() > 40) return {};
|
||||
space();
|
||||
if (pos == s.size() || s[pos++] != ':') return {};
|
||||
space();
|
||||
bool quoted = pos < s.size() && s[pos] == '"';
|
||||
if (quoted) { if (!json_string(s, pos, value)) return {}; }
|
||||
else {
|
||||
size_t start = pos;
|
||||
while (pos < s.size() && s[pos] != ',' && s[pos] != '}' && s[pos] != ' ' && s[pos] != '\t') ++pos;
|
||||
value = s.substr(start, pos - start);
|
||||
if (value != "true" && value != "false" && value != "null" &&
|
||||
(value.empty() || !std::all_of(value.begin(), value.end(), [](unsigned char c) { return c >= '0' && c <= '9'; }))) return {};
|
||||
}
|
||||
if (!result.emplace(std::move(key), JsonValue{std::move(value), quoted}).second) return {};
|
||||
space();
|
||||
if (pos < s.size() && s[pos] == '}') { ++pos; closed = true; break; }
|
||||
if (pos == s.size() || s[pos++] != ',') return {};
|
||||
space();
|
||||
}
|
||||
space();
|
||||
if (!closed || pos != s.size()) return {};
|
||||
return result;
|
||||
}
|
||||
std::string safe_label(const std::string& s, size_t max) {
|
||||
std::string result;
|
||||
for (unsigned char c : s) {
|
||||
if (result.size() == max) break;
|
||||
result += c >= 32 && c < 127 ? char(c) : '?';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::vector<std::string> fields(const std::string& line) {
|
||||
std::vector<std::string> result;
|
||||
size_t start = 0;
|
||||
while (start <= line.size()) {
|
||||
size_t end = line.find('\t', start);
|
||||
if (end == std::string::npos) end = line.size();
|
||||
result.push_back(decode(line.substr(start, end - start)));
|
||||
start = end + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
BackendManager::BackendManager(std::string python, std::string service, std::string manifest_dir,
|
||||
std::string model_store, std::string installer, std::string legacy_model)
|
||||
: python_(std::move(python)), service_(std::move(service)), manifest_(std::move(manifest_dir)),
|
||||
store_(std::move(model_store)), installer_(std::move(installer)), legacy_model_(std::move(legacy_model)) {}
|
||||
std::string BackendManager::model_path(const std::string& id) const {
|
||||
auto it = std::find_if(entries_.begin(), entries_.end(), [&](const auto& entry) { return entry.id == id; });
|
||||
return it == entries_.end() ? store_ + "/" + id : it->model_path;
|
||||
}
|
||||
BackendManager::~BackendManager() { cancel(); }
|
||||
void BackendManager::cancel() noexcept {
|
||||
if (pid_ > 0) {
|
||||
::kill(pid_, SIGTERM); // exact owned child only; service execs installer
|
||||
int status = 0;
|
||||
bool reaped = false;
|
||||
// Give the owned installer a short chance to remove its own download
|
||||
// temp in a SIGTERM handler; never wait indefinitely on the overlay.
|
||||
for (int n = 0; n < 20; ++n) {
|
||||
pid_t result = ::waitpid(pid_, &status, WNOHANG);
|
||||
if (result == pid_ || (result < 0 && errno == ECHILD)) { reaped = true; break; }
|
||||
if (result < 0 && errno != EINTR) break;
|
||||
::usleep(5000);
|
||||
}
|
||||
if (!reaped) {
|
||||
::kill(pid_, SIGKILL);
|
||||
while (::waitpid(pid_, &status, 0) < 0 && errno == EINTR) {}
|
||||
}
|
||||
pid_ = -1;
|
||||
}
|
||||
if (output_ >= 0) { ::close(output_); output_ = -1; }
|
||||
buffer_.clear(); installing_ = false; eof_ = false; output_bytes_ = 0;
|
||||
}
|
||||
void BackendManager::launch(std::vector<std::string> args, bool installing) {
|
||||
if (busy()) throw std::logic_error("backend operation already running");
|
||||
int pipe[2];
|
||||
if (::pipe2(pipe, O_CLOEXEC)) throw std::runtime_error("backend status pipe unavailable");
|
||||
// Pipe ends have independent flags: only the parent's read must be
|
||||
// nonblocking. A nonblocking child stdout can lose a burst to EAGAIN.
|
||||
if (::fcntl(pipe[0], F_SETFL, O_NONBLOCK) < 0) {
|
||||
::close(pipe[0]); ::close(pipe[1]);
|
||||
throw std::runtime_error("backend status pipe unavailable");
|
||||
}
|
||||
posix_spawn_file_actions_t actions;
|
||||
int error = posix_spawn_file_actions_init(&actions);
|
||||
bool actions_initialized = error == 0;
|
||||
if (!error) error = posix_spawn_file_actions_adddup2(&actions, pipe[1], STDOUT_FILENO);
|
||||
if (!error) error = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
|
||||
if (!error) error = posix_spawn_file_actions_addclose(&actions, pipe[0]);
|
||||
if (!error) error = posix_spawn_file_actions_addclose(&actions, pipe[1]);
|
||||
std::vector<char*> argv;
|
||||
for (auto& arg : args) argv.push_back(arg.data());
|
||||
argv.push_back(nullptr);
|
||||
pid_t child = -1;
|
||||
if (!error) error = posix_spawnp(&child, args[0].c_str(), &actions, nullptr, argv.data(), environ);
|
||||
if (actions_initialized) posix_spawn_file_actions_destroy(&actions);
|
||||
::close(pipe[1]);
|
||||
if (error) { ::close(pipe[0]); throw std::runtime_error("could not start local backend helper"); }
|
||||
pid_ = child; output_ = pipe[0]; installing_ = installing;
|
||||
done_ = failed_ = eof_ = false; buffer_.clear(); output_bytes_ = 0;
|
||||
if (installing) { install_failed_ = false; install_error_.clear(); }
|
||||
deadline_ = std::chrono::steady_clock::now() + (installing ? std::chrono::minutes(30) : std::chrono::minutes(5));
|
||||
note_ = installing ? "Downloading pinned model after explicit consent..." :
|
||||
install_failed_ ? "Install failed: " +
|
||||
(install_error_.empty() ? std::string("installer exited unsuccessfully") : install_error_) +
|
||||
"; rechecking local model files..." : "Checking local model files (offline)...";
|
||||
}
|
||||
void BackendManager::refresh() {
|
||||
if (busy()) return;
|
||||
entries_.clear(); checked_ = false;
|
||||
std::vector<std::string> args{python_, service_, "--status", "--manifest-dir", manifest_, "--model-store", store_};
|
||||
if (!legacy_model_.empty()) args.insert(args.end(), {"--legacy-model", legacy_model_});
|
||||
launch(std::move(args), false);
|
||||
}
|
||||
void BackendManager::install(const std::string& id, const std::string& consent_sha256) {
|
||||
if (busy() || !checked_ || installer_.empty()) throw std::runtime_error("Installer unavailable or model status unchecked");
|
||||
auto found = std::find_if(entries_.begin(), entries_.end(), [&](const auto& e) { return e.id == id; });
|
||||
if (found == entries_.end() || found->state == "installed_verified" ||
|
||||
found->manifest_sha256 != consent_sha256 || consent_sha256.size() != 64)
|
||||
throw std::runtime_error("Selected backend metadata changed; review consent again");
|
||||
launch({python_, service_, "--install", "--backend", id, "--installer", installer_,
|
||||
"--manifest-dir", manifest_, "--model-store", store_,
|
||||
"--expected-manifest-sha256", consent_sha256}, true);
|
||||
}
|
||||
void BackendManager::line(const std::string& text) {
|
||||
if (installing_) {
|
||||
// These are advisory, bounded display events only. Exit zero followed
|
||||
// by a fresh offline check is the sole route to installed status.
|
||||
auto object = json_event(text);
|
||||
if (!object) return;
|
||||
auto field = [&](const char* key, bool quoted) -> std::string {
|
||||
auto it = object->find(key);
|
||||
return it != object->end() && it->second.quoted == quoted ? it->second.text : "";
|
||||
};
|
||||
if (field("ok", false) == "false") {
|
||||
const auto code = field("code", true);
|
||||
const auto message = field("message", true);
|
||||
if (!message.empty()) {
|
||||
install_error_ = safe_label(message, 130);
|
||||
if (code == "manifest_mismatch" || code == "usage")
|
||||
install_error_ = code + ": " + install_error_;
|
||||
note_ = "Install error: " + install_error_;
|
||||
++revision_;
|
||||
}
|
||||
} else if (field("ok", false) == "true" && field("event", true) == "model_file") {
|
||||
auto state = field("state", true);
|
||||
auto file = field("file", true);
|
||||
if ((state == "downloading" || state == "verified") && !file.empty()) {
|
||||
note_ = (state == "downloading" ? "Downloading: " : "Verified file: ") + safe_label(file, 95);
|
||||
++revision_;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (done_ || failed_) throw std::runtime_error("backend status after terminal record");
|
||||
const auto f = fields(text);
|
||||
if (f.size() == 1 && f[0] == "DONE") { done_ = true; return; }
|
||||
if (f.size() == 2 && f[0] == "ERROR") { failed_ = true; return; }
|
||||
if (f.size() != 12 || f[0] != "ST" || f[1].empty() || f[1].size() > 48 ||
|
||||
(f[3] != "not_installed" && f[3] != "installed_verified" && f[3] != "invalid") ||
|
||||
entries_.size() >= 32) throw std::runtime_error("invalid backend status record");
|
||||
uint64_t bytes = 0;
|
||||
auto [end, error] = std::from_chars(f[5].data(), f[5].data() + f[5].size(), bytes);
|
||||
if (error != std::errc{} || end != f[5].data() + f[5].size() ||
|
||||
std::any_of(entries_.begin(), entries_.end(), [&](const auto& e) { return e.id == f[1]; }))
|
||||
throw std::runtime_error("invalid backend status metadata");
|
||||
static const std::regex id_pattern("[a-z][a-z0-9_-]{0,47}");
|
||||
static const std::regex digest_pattern("[0-9a-f]{64}");
|
||||
if (f[10].empty() || f[10][0] != '/' || !std::regex_match(f[1], id_pattern) ||
|
||||
!std::regex_match(f[11], digest_pattern)) throw std::runtime_error("invalid model location or fingerprint");
|
||||
entries_.push_back({f[1], f[2], f[3], f[4], f[6], f[7], f[8], f[9], f[10], bytes, f[11]});
|
||||
}
|
||||
void BackendManager::poll() {
|
||||
if (!busy()) return;
|
||||
try {
|
||||
if (std::chrono::steady_clock::now() > deadline_) throw std::runtime_error("backend operation timed out");
|
||||
size_t budget = 64 * 1024;
|
||||
char chunk[4096];
|
||||
while (!eof_ && budget) {
|
||||
ssize_t count = ::read(output_, chunk, std::min(budget, sizeof chunk));
|
||||
if (count > 0) {
|
||||
budget -= size_t(count); output_bytes_ += size_t(count);
|
||||
if (output_bytes_ > 256 * 1024) throw std::runtime_error("excessive backend output");
|
||||
buffer_.append(chunk, size_t(count));
|
||||
for (auto end = buffer_.find('\n'); end != std::string::npos; end = buffer_.find('\n')) {
|
||||
if (end > 8192) throw std::runtime_error("oversized backend status line");
|
||||
line(buffer_.substr(0, end)); buffer_.erase(0, end + 1);
|
||||
}
|
||||
if (buffer_.size() > 8192) throw std::runtime_error("oversized backend status line");
|
||||
} else if (count == 0) { eof_ = true; ::close(output_); output_ = -1; }
|
||||
else if (errno == EINTR) continue;
|
||||
else if (errno == EAGAIN || errno == EWOULDBLOCK) break;
|
||||
else throw std::runtime_error("backend status pipe failed");
|
||||
}
|
||||
if (!eof_) return; // a successful child may still have unread pipe data
|
||||
int status = 0;
|
||||
pid_t result = ::waitpid(pid_, &status, WNOHANG);
|
||||
if (result < 0) throw std::runtime_error("backend helper wait failed");
|
||||
if (!result) return;
|
||||
pid_ = -1;
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0 || failed_ || !buffer_.empty() ||
|
||||
(!installing_ && !done_)) {
|
||||
bool was_installing = installing_;
|
||||
installing_ = false;
|
||||
if (was_installing) {
|
||||
// Source metadata may have changed since consent. Recheck before
|
||||
// another attempt; never keep stale manifest consent reusable.
|
||||
install_failed_ = true;
|
||||
checked_ = false;
|
||||
refresh();
|
||||
} else note_ = install_failed_ ? "Install failed: " +
|
||||
(install_error_.empty() ? std::string("installer exited unsuccessfully") : install_error_) +
|
||||
"; local model recheck failed." : "Local model check failed.";
|
||||
++revision_;
|
||||
return;
|
||||
}
|
||||
if (installing_) { installing_ = false; note_ = "Install finished; verifying local model files..."; refresh(); }
|
||||
else { checked_ = true; note_ = install_failed_ ? "Install failed: " +
|
||||
(install_error_.empty() ? std::string("installer exited unsuccessfully") : install_error_) +
|
||||
"; local model status checked offline." : "Local model status checked offline."; ++revision_; }
|
||||
} catch (const std::exception& error) {
|
||||
bool was_installing = installing_;
|
||||
if (was_installing && install_error_.empty()) install_error_ = safe_label(error.what(), 130);
|
||||
cancel();
|
||||
if (was_installing) checked_ = false;
|
||||
if (was_installing) install_failed_ = true;
|
||||
note_ = was_installing ? "Install failed: " + install_error_ + ". Restart to recheck models."
|
||||
: install_failed_ ? "Install failed: " + install_error_ + "; local model recheck failed."
|
||||
: "Local model check failed.";
|
||||
++revision_;
|
||||
}
|
||||
}
|
||||
} // namespace frameyap
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sys/types.h>
|
||||
|
||||
namespace frameyap {
|
||||
struct BackendEntry {
|
||||
std::string id, name, state, reason, source, license, license_text, attribution, model_path;
|
||||
uint64_t bytes = 0;
|
||||
std::string manifest_sha256;
|
||||
};
|
||||
// All hashing/metadata comes from model_files.py via backend-service.py.
|
||||
// This class never opens the network or blocks waiting for verification/install.
|
||||
class BackendManager {
|
||||
public:
|
||||
BackendManager(std::string python, std::string service, std::string manifest_dir,
|
||||
std::string model_store, std::string installer, std::string legacy_model = {});
|
||||
~BackendManager();
|
||||
BackendManager(const BackendManager&) = delete;
|
||||
BackendManager& operator=(const BackendManager&) = delete;
|
||||
void refresh();
|
||||
void install(const std::string& id, const std::string& consent_sha256); // immutable digest captured from displayed confirmation
|
||||
void poll();
|
||||
void cancel() noexcept;
|
||||
const std::vector<BackendEntry>& entries() const { return entries_; }
|
||||
const std::string& note() const { return note_; }
|
||||
bool busy() const { return pid_ > 0; }
|
||||
bool installing() const { return installing_; }
|
||||
size_t revision() const { return revision_; }
|
||||
bool checked() const { return checked_; }
|
||||
std::string model_path(const std::string& id) const;
|
||||
const std::string& manifest_dir() const { return manifest_; }
|
||||
private:
|
||||
void launch(std::vector<std::string> arguments, bool installing);
|
||||
void line(const std::string& text);
|
||||
std::string python_, service_, manifest_, store_, installer_, legacy_model_;
|
||||
std::vector<BackendEntry> entries_;
|
||||
std::string note_ = "Checking local models (offline)...";
|
||||
std::string buffer_;
|
||||
pid_t pid_ = -1;
|
||||
int output_ = -1;
|
||||
size_t revision_ = 0;
|
||||
bool installing_ = false, done_ = false, failed_ = false, checked_ = false;
|
||||
bool install_failed_ = false; // retain failed-install feedback through the offline recheck
|
||||
std::string install_error_; // sanitized, bounded installer error; not success authority
|
||||
bool eof_ = false;
|
||||
size_t output_bytes_ = 0;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
}
|
||||
+136
-108
@@ -76,26 +76,33 @@ struct Overlay::Impl {
|
||||
std::unique_ptr<OverlayTexture> gpu_texture;
|
||||
vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle;
|
||||
std::array<vr::VRActionHandle_t, 7> actions{};
|
||||
std::filesystem::path settings_path;
|
||||
std::filesystem::path laser_settings_path;
|
||||
struct Persistence {
|
||||
std::filesystem::path settings_path, laser_settings_path;
|
||||
bool save_failed = false, debug_save_failed = false, auto_save_failed = false;
|
||||
bool layout_save_failed = false, mic_save_failed = false, laser_change_failed = false;
|
||||
bool persist_mount = true;
|
||||
} persistence;
|
||||
Mount mount;
|
||||
bool lasers_anytime = false, laser_change_failed = false;
|
||||
bool lasers_anytime = false;
|
||||
Config config;
|
||||
PanelSurface surface;
|
||||
Panel panel;
|
||||
bool save_failed = false, debug_save_failed = false, auto_save_failed = false, layout_save_failed = false;
|
||||
std::vector<ModelAction> model_actions;
|
||||
bool world_ready = false, placed = false, has_texture = false, shown = false;
|
||||
float published_alpha = -1.f;
|
||||
float size_scale = 1.f;
|
||||
bool placement_dirty = false;
|
||||
Matrix34 canvas_pose{}, drag_canvas{};
|
||||
PanelDrag drag;
|
||||
PanelDragKind drag_kind = PanelDragKind::Grab;
|
||||
unsigned drag_cursor = 0;
|
||||
vr::TrackedDeviceIndex_t drag_device = vr::k_unTrackedDeviceIndexInvalid;
|
||||
bool drag_trigger_observed = false;
|
||||
float drag_scale = 1.f;
|
||||
std::chrono::steady_clock::time_point drag_started{};
|
||||
Matrix34 canvas_pose{};
|
||||
struct DragState {
|
||||
Matrix34 canvas{};
|
||||
PanelDrag panel;
|
||||
PanelDragKind kind = PanelDragKind::Grab;
|
||||
unsigned cursor = 0;
|
||||
vr::TrackedDeviceIndex_t device = vr::k_unTrackedDeviceIndexInvalid;
|
||||
bool trigger_observed = false;
|
||||
float scale = 1.f;
|
||||
std::chrono::steady_clock::time_point started{};
|
||||
} dragging;
|
||||
vr::HmdMatrix34_t world_transform{};
|
||||
std::optional<Mount> applied_mount;
|
||||
vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid;
|
||||
@@ -105,22 +112,22 @@ struct Overlay::Impl {
|
||||
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses{};
|
||||
bool grip_capture = false, ptt_capture = false;
|
||||
bool focus = true;
|
||||
bool persist_mount = true;
|
||||
vr::EVRInputError action_update_error = vr::VRInputError_None;
|
||||
unsigned pointer_downs = 0, pointer_ups = 0, pointer_actions = 0, pointer_resets = 0;
|
||||
unsigned texture_uploads = 0, show_calls = 0, hide_calls = 0;
|
||||
unsigned overlay_shown_events = 0, overlay_hidden_events = 0, image_loaded_events = 0, image_failed_events = 0;
|
||||
unsigned overlay_focus_events = 0, global_focus_events = 0, input_focus_captured_events = 0;
|
||||
std::string last_pointer_event = "none";
|
||||
struct Diagnostics {
|
||||
vr::EVRInputError action_update_error = vr::VRInputError_None;
|
||||
unsigned pointer_downs = 0, pointer_ups = 0, pointer_actions = 0, pointer_resets = 0;
|
||||
unsigned texture_uploads = 0, show_calls = 0, hide_calls = 0;
|
||||
unsigned overlay_shown_events = 0, overlay_hidden_events = 0, image_loaded_events = 0, image_failed_events = 0;
|
||||
unsigned overlay_focus_events = 0, global_focus_events = 0, input_focus_captured_events = 0;
|
||||
std::string last_pointer_event = "none";
|
||||
} diagnostics;
|
||||
|
||||
Impl(const std::string& assets, const std::string& font, std::optional<Mount> requested, bool persist)
|
||||
: settings_path(default_mount_settings_path()),
|
||||
laser_settings_path(default_laser_settings_path()),
|
||||
mount(requested ? *requested : load_mount(settings_path)),
|
||||
lasers_anytime(load_lasers_anytime(laser_settings_path)),
|
||||
: persistence{default_mount_settings_path(), default_laser_settings_path()},
|
||||
mount(requested ? *requested : load_mount(persistence.settings_path)),
|
||||
lasers_anytime(load_lasers_anytime(persistence.laser_settings_path)),
|
||||
config(load_config(default_config_path())),
|
||||
surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme),
|
||||
persist_mount(persist) {
|
||||
surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme) {
|
||||
persistence.persist_mount = persist;
|
||||
const auto action_path = absolute_file(action_manifest(assets, config));
|
||||
absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json");
|
||||
try {
|
||||
@@ -167,11 +174,12 @@ struct Overlay::Impl {
|
||||
// panel is visible; it may affect interaction with a running game.
|
||||
if (lasers_anytime && overlay->SetOverlayFlag(handle, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true) != vr::VROverlayError_None) {
|
||||
lasers_anytime = false;
|
||||
laser_change_failed = true;
|
||||
persistence.laser_change_failed = true;
|
||||
}
|
||||
surface.set_lasers_anytime(lasers_anytime);
|
||||
surface.set_advanced_debug(config.advanced_debug);
|
||||
surface.set_auto_insert(config.auto_insert);
|
||||
surface.set_close_mic_when_idle(config.close_mic_when_idle);
|
||||
surface.set_layout_locked(config.lock_layout);
|
||||
surface.set_clock_24h(config.clock_24h);
|
||||
surface.set_date_format(config.date_format);
|
||||
@@ -228,7 +236,7 @@ struct Overlay::Impl {
|
||||
const bool wanted = placed && has_texture && alpha > 0.f;
|
||||
if (wanted == shown) return;
|
||||
overlay_check(wanted ? overlay->ShowOverlay(handle) : overlay->HideOverlay(handle), overlay, "Overlay visibility");
|
||||
if (wanted) ++show_calls; else ++hide_calls;
|
||||
if (wanted) ++diagnostics.show_calls; else ++diagnostics.hide_calls;
|
||||
shown = wanted;
|
||||
}
|
||||
void place() {
|
||||
@@ -242,12 +250,13 @@ struct Overlay::Impl {
|
||||
}
|
||||
if (effective == Mount::World && applied_mount && *applied_mount != Mount::World)
|
||||
world_ready = false; // a fresh world fallback near the wearer, not an old room location
|
||||
std::string note = layout_save_failed ? "Layout lock not saved; using it only for this session." :
|
||||
auto_save_failed ? "Auto insert preference not saved; using it only for this session." :
|
||||
debug_save_failed ? "Debug preference not saved; using it only for this session." :
|
||||
laser_change_failed ? "SteamVR declined the laser mode change." :
|
||||
save_failed ? "Preference could not be saved; using it for this session." : "";
|
||||
if (effective != mount) note = save_failed ? "Wrist untracked; world fallback. Preference not saved." :
|
||||
std::string note = persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." :
|
||||
persistence.layout_save_failed ? "Layout lock not saved; using it only for this session." :
|
||||
persistence.auto_save_failed ? "Auto insert preference not saved; using it only for this session." :
|
||||
persistence.debug_save_failed ? "Debug preference not saved; using it only for this session." :
|
||||
persistence.laser_change_failed ? "SteamVR declined the laser mode change." :
|
||||
persistence.save_failed ? "Preference could not be saved; using it for this session." : "";
|
||||
if (effective != mount) note = persistence.save_failed ? "Wrist untracked; world fallback. Preference not saved." :
|
||||
"Wrist not tracked - using world space until it returns.";
|
||||
if (effective == Mount::World && !world_ready) {
|
||||
const auto& hmd = poses[vr::k_unTrackedDeviceIndex_Hmd];
|
||||
@@ -269,7 +278,7 @@ struct Overlay::Impl {
|
||||
if (relocated || placement_dirty) {
|
||||
const float base_width = mount_width(effective, config.wrist);
|
||||
if (relocated) {
|
||||
surface.reset_pointers(); drag.reset();
|
||||
surface.reset_pointers(); dragging.panel.reset();
|
||||
auto pose = effective == Mount::World ? matrix(world_transform) : relative_mount_pose(effective, config.wrist);
|
||||
pose = resized_mount_pose(pose, base_width, size_scale, float(CH) / CW);
|
||||
// Preserve main-panel dimensions when adding transparent margins.
|
||||
@@ -301,8 +310,8 @@ struct Overlay::Impl {
|
||||
return device < poses.size() && poses[device].bPoseIsValid && poses[device].bDeviceIsConnected;
|
||||
}
|
||||
std::optional<Matrix34> drag_source() const {
|
||||
if (!tracked(drag_device) || !applied_mount) return {};
|
||||
auto pose = matrix(poses[drag_device].mDeviceToAbsoluteTracking);
|
||||
if (!tracked(dragging.device) || !applied_mount) return {};
|
||||
auto pose = matrix(poses[dragging.device].mDeviceToAbsoluteTracking);
|
||||
if (*applied_mount != Mount::World) {
|
||||
if (!tracked(anchor)) return {};
|
||||
pose = relative_pose(matrix(poses[anchor].mDeviceToAbsoluteTracking), pose);
|
||||
@@ -310,53 +319,53 @@ struct Overlay::Impl {
|
||||
return pose;
|
||||
}
|
||||
void begin_drag(PanelDragKind kind, const vr::VREvent_t& event) {
|
||||
if (config.lock_layout) { surface.reset_pointers(); drag.reset(); return; }
|
||||
drag_device = event.trackedDeviceIndex;
|
||||
drag_cursor = event.data.mouse.cursorIndex;
|
||||
if (config.lock_layout) { surface.reset_pointers(); dragging.panel.reset(); return; }
|
||||
dragging.device = event.trackedDeviceIndex;
|
||||
dragging.cursor = event.data.mouse.cursorIndex;
|
||||
// Single-cursor overlay: some runtime mouse events omit the source.
|
||||
// The dashboard's primary device is the only supported fallback, never
|
||||
// a guessed left/right hand or a source chosen by proximity.
|
||||
if (drag_cursor == 0 && (drag_device == vr::k_unTrackedDeviceIndexInvalid ||
|
||||
drag_device == vr::k_unTrackedDeviceIndex_Hmd))
|
||||
drag_device = overlay->GetPrimaryDashboardDevice();
|
||||
drag_kind = kind;
|
||||
if (dragging.cursor == 0 && (dragging.device == vr::k_unTrackedDeviceIndexInvalid ||
|
||||
dragging.device == vr::k_unTrackedDeviceIndex_Hmd))
|
||||
dragging.device = overlay->GetPrimaryDashboardDevice();
|
||||
dragging.kind = kind;
|
||||
const auto source = drag_source();
|
||||
const float w = applied_mount ? mount_width(*applied_mount, config.wrist) * size_scale * W / CW : 0.f;
|
||||
if (!source || system->GetTrackedDeviceClass(drag_device) != vr::TrackedDeviceClass_Controller ||
|
||||
!drag.begin(kind, canvas_pose, w, w * H / W, event.data.mouse.x / W,
|
||||
1.f - event.data.mouse.y / H, *source)) {
|
||||
surface.reset_pointers(); drag.reset();
|
||||
last_pointer_event = "drag source unavailable";
|
||||
if (!source || system->GetTrackedDeviceClass(dragging.device) != vr::TrackedDeviceClass_Controller ||
|
||||
!dragging.panel.begin(kind, canvas_pose, w, w * H / W, event.data.mouse.x / W,
|
||||
1.f - event.data.mouse.y / H, *source)) {
|
||||
surface.reset_pointers(); dragging.panel.reset();
|
||||
diagnostics.last_pointer_event = "drag source unavailable";
|
||||
return;
|
||||
}
|
||||
drag_scale = size_scale; drag_canvas = canvas_pose;
|
||||
drag_started = std::chrono::steady_clock::now();
|
||||
dragging.scale = size_scale; dragging.canvas = canvas_pose;
|
||||
dragging.started = std::chrono::steady_clock::now();
|
||||
vr::VRControllerState_t state{};
|
||||
drag_trigger_observed = system->GetControllerState(drag_device, &state, sizeof(state)) &&
|
||||
dragging.trigger_observed = system->GetControllerState(dragging.device, &state, sizeof(state)) &&
|
||||
(state.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger));
|
||||
last_pointer_event = std::string(kind == PanelDragKind::Grab ? "grab" : "scale") +
|
||||
" device=" + std::to_string(drag_device) + " trigger-watch=" + (drag_trigger_observed ? "Y" : "N");
|
||||
diagnostics.last_pointer_event = std::string(kind == PanelDragKind::Grab ? "grab" : "scale") +
|
||||
" device=" + std::to_string(dragging.device) + " trigger-watch=" + (dragging.trigger_observed ? "Y" : "N");
|
||||
}
|
||||
void update_drag() {
|
||||
if (!drag.active()) return;
|
||||
if (!dragging.panel.active()) return;
|
||||
const auto source = drag_source();
|
||||
vr::VRControllerState_t state{};
|
||||
const bool trigger_released = drag_trigger_observed &&
|
||||
(!system->GetControllerState(drag_device, &state, sizeof(state)) ||
|
||||
const bool trigger_released = dragging.trigger_observed &&
|
||||
(!system->GetControllerState(dragging.device, &state, sizeof(state)) ||
|
||||
!(state.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger)));
|
||||
// Without a readable release watchdog, never keep manipulating after
|
||||
// the pointer leaves our hit region: an outside MouseUp is not assured.
|
||||
const bool lost_unwatched_pointer = !drag_trigger_observed && !overlay->IsHoverTargetOverlay(handle);
|
||||
if (!surface.dragging(drag_cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer ||
|
||||
std::chrono::steady_clock::now() - drag_started > std::chrono::seconds(15)) {
|
||||
surface.reset_pointers(); drag.reset(); return;
|
||||
const bool lost_unwatched_pointer = !dragging.trigger_observed && !overlay->IsHoverTargetOverlay(handle);
|
||||
if (!surface.dragging(dragging.cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer ||
|
||||
std::chrono::steady_clock::now() - dragging.started > std::chrono::seconds(15)) {
|
||||
surface.reset_pointers(); dragging.panel.reset(); return;
|
||||
}
|
||||
const auto change = drag.update(*source);
|
||||
if (!change) { surface.reset_pointers(); drag.reset(); return; }
|
||||
const float scale = drag_kind == PanelDragKind::Scale ? std::clamp(drag_scale * change->factor, .5f, 2.f) : size_scale;
|
||||
const auto pose = drag_kind == PanelDragKind::Grab ? change->pose :
|
||||
resized_mount_pose(drag_canvas, mount_width(*applied_mount, config.wrist) * drag_scale * W / CW,
|
||||
scale / drag_scale, float(H) / W);
|
||||
const auto change = dragging.panel.update(*source);
|
||||
if (!change) { surface.reset_pointers(); dragging.panel.reset(); return; }
|
||||
const float scale = dragging.kind == PanelDragKind::Scale ? std::clamp(dragging.scale * change->factor, .5f, 2.f) : size_scale;
|
||||
const auto pose = dragging.kind == PanelDragKind::Grab ? change->pose :
|
||||
resized_mount_pose(dragging.canvas, mount_width(*applied_mount, config.wrist) * dragging.scale * W / CW,
|
||||
scale / dragging.scale, float(H) / W);
|
||||
if (scale != size_scale || pose != canvas_pose) {
|
||||
size_scale = scale; canvas_pose = pose; placement_dirty = true;
|
||||
}
|
||||
@@ -369,7 +378,7 @@ struct Overlay::Impl {
|
||||
gpu_texture->upload(surface.pixels());
|
||||
auto texture = gpu_texture->texture();
|
||||
overlay_check(overlay->SetOverlayTexture(handle, &texture), overlay, "SetOverlayTexture (Vulkan)");
|
||||
++texture_uploads;
|
||||
++diagnostics.texture_uploads;
|
||||
has_texture = true;
|
||||
}
|
||||
visibility();
|
||||
@@ -431,8 +440,8 @@ struct Overlay::Impl {
|
||||
vr::VREvent_t event{};
|
||||
while (system->PollNextEvent(&event, sizeof(event))) {
|
||||
if (event.eventType == vr::VREvent_Quit) result.push_back(UiAction::Quit);
|
||||
if (event.eventType == vr::VREvent_OverlayFocusChanged) ++global_focus_events;
|
||||
if (event.eventType == vr::VREvent_InputFocusCaptured) ++input_focus_captured_events;
|
||||
if (event.eventType == vr::VREvent_OverlayFocusChanged) ++diagnostics.global_focus_events;
|
||||
if (event.eventType == vr::VREvent_InputFocusCaptured) ++diagnostics.input_focus_captured_events;
|
||||
if (event.eventType == vr::VREvent_SeatedZeroPoseReset || event.eventType == vr::VREvent_ChaperoneUniverseHasChanged) {
|
||||
world_ready = false; applied_mount.reset(); surface.reset_pointers();
|
||||
}
|
||||
@@ -444,52 +453,57 @@ struct Overlay::Impl {
|
||||
case vr::VREvent_Quit: case vr::VREvent_OverlayClosed:
|
||||
result.push_back(UiAction::Quit); break;
|
||||
case vr::VREvent_OverlayHidden:
|
||||
++overlay_hidden_events;
|
||||
++diagnostics.overlay_hidden_events;
|
||||
focus = false; reset_input(result);
|
||||
if (panel.recording) result.push_back(UiAction::Cancel);
|
||||
break;
|
||||
case vr::VREvent_OverlayShown:
|
||||
++overlay_shown_events;
|
||||
++diagnostics.overlay_shown_events;
|
||||
focus = true; reset_input(result); break;
|
||||
case vr::VREvent_ImageLoaded: ++image_loaded_events; break;
|
||||
case vr::VREvent_ImageFailed: ++image_failed_events; break;
|
||||
case vr::VREvent_ImageLoaded: ++diagnostics.image_loaded_events; break;
|
||||
case vr::VREvent_ImageFailed: ++diagnostics.image_failed_events; break;
|
||||
case vr::VREvent_OverlayGamepadFocusLost:
|
||||
surface.reset_pointers(); ++pointer_resets;
|
||||
last_pointer_event = "gamepad focus lost"; break;
|
||||
surface.reset_pointers(); ++diagnostics.pointer_resets;
|
||||
diagnostics.last_pointer_event = "gamepad focus lost"; break;
|
||||
case vr::VREvent_OverlayFocusChanged:
|
||||
++overlay_focus_events;
|
||||
++diagnostics.overlay_focus_events;
|
||||
// This global focus notification also fires when the dashboard
|
||||
// laser enters our overlay. Do not erase a press between down/up;
|
||||
// a release must still hit the same enabled control.
|
||||
last_pointer_event = "overlay focus changed"; break;
|
||||
diagnostics.last_pointer_event = "overlay focus changed"; break;
|
||||
case vr::VREvent_MouseMove:
|
||||
// Manipulation uses the captured controller ray, not coordinates
|
||||
// fed back from a changing overlay or a batch of stale mouse hits.
|
||||
break;
|
||||
case vr::VREvent_MouseButtonDown:
|
||||
++pointer_downs;
|
||||
last_pointer_event = "down button=" + std::to_string(event.data.mouse.button);
|
||||
++diagnostics.pointer_downs;
|
||||
diagnostics.last_pointer_event = "down button=" + std::to_string(event.data.mouse.button);
|
||||
if (event.data.mouse.button == vr::VRMouseButton_Left)
|
||||
if (auto kind = surface.pointer_down(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y))
|
||||
begin_drag(*kind, event);
|
||||
break;
|
||||
case vr::VREvent_MouseButtonUp:
|
||||
++pointer_ups;
|
||||
last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
|
||||
++diagnostics.pointer_ups;
|
||||
diagnostics.last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
|
||||
if (event.data.mouse.button == vr::VRMouseButton_Left) {
|
||||
auto event_result = surface.pointer_up(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y);
|
||||
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug || event_result.auto_insert || event_result.lock_layout || event_result.clock_24h || event_result.date_format) ++pointer_actions;
|
||||
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions;
|
||||
if (event_result.action) result.push_back(*event_result.action);
|
||||
if (event_result.model_action) {
|
||||
model_actions.push_back(*event_result.model_action);
|
||||
reset_input(result); // revoke held PTT, delivery and stale pointer approval
|
||||
return result;
|
||||
}
|
||||
if (event_result.clock_24h) {
|
||||
config.clock_24h = *event_result.clock_24h;
|
||||
save_failed = persist_mount && !save_clock_24h(default_config_path(), config.clock_24h);
|
||||
persistence.save_failed = persistence.persist_mount && !save_clock_24h(default_config_path(), config.clock_24h);
|
||||
surface.set_clock_24h(config.clock_24h);
|
||||
reset_input(result);
|
||||
return result;
|
||||
}
|
||||
if (event_result.date_format) {
|
||||
config.date_format = *event_result.date_format;
|
||||
save_failed = persist_mount && !save_date_format(default_config_path(), config.date_format);
|
||||
persistence.save_failed = persistence.persist_mount && !save_date_format(default_config_path(), config.date_format);
|
||||
surface.set_date_format(config.date_format);
|
||||
reset_input(result);
|
||||
return result;
|
||||
@@ -498,20 +512,28 @@ struct Overlay::Impl {
|
||||
config.lock_layout = *event_result.lock_layout;
|
||||
surface.set_layout_locked(config.lock_layout);
|
||||
update_intersection_mask();
|
||||
layout_save_failed = persist_mount && !save_lock_layout(default_config_path(), config.lock_layout);
|
||||
reset_input(result); drag.reset();
|
||||
persistence.layout_save_failed = persistence.persist_mount && !save_lock_layout(default_config_path(), config.lock_layout);
|
||||
reset_input(result); dragging.panel.reset();
|
||||
return result;
|
||||
}
|
||||
if (event_result.close_mic_when_idle) {
|
||||
config.close_mic_when_idle = *event_result.close_mic_when_idle;
|
||||
persistence.mic_save_failed = persistence.persist_mount &&
|
||||
!save_close_mic_when_idle(default_config_path(), config.close_mic_when_idle);
|
||||
surface.set_close_mic_when_idle(config.close_mic_when_idle);
|
||||
reset_input(result); // a setting change invalidates held actions
|
||||
return result;
|
||||
}
|
||||
if (event_result.auto_insert) {
|
||||
config.auto_insert = *event_result.auto_insert;
|
||||
auto_save_failed = persist_mount && !save_auto_insert(default_config_path(), config.auto_insert);
|
||||
persistence.auto_save_failed = persistence.persist_mount && !save_auto_insert(default_config_path(), config.auto_insert);
|
||||
surface.set_auto_insert(config.auto_insert);
|
||||
reset_input(result); // setting change invalidates held actions
|
||||
return result;
|
||||
}
|
||||
if (event_result.advanced_debug) {
|
||||
config.advanced_debug = *event_result.advanced_debug;
|
||||
debug_save_failed = persist_mount && !save_advanced_debug(default_config_path(), config.advanced_debug);
|
||||
persistence.debug_save_failed = persistence.persist_mount && !save_advanced_debug(default_config_path(), config.advanced_debug);
|
||||
surface.set_advanced_debug(config.advanced_debug);
|
||||
reset_input(result);
|
||||
// Runtime observes the change before handling this batch,
|
||||
@@ -532,16 +554,16 @@ struct Overlay::Impl {
|
||||
if (overlay->SetOverlayFlag(handle, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,
|
||||
enabled) == vr::VROverlayError_None) {
|
||||
lasers_anytime = enabled;
|
||||
laser_change_failed = false;
|
||||
persistence.laser_change_failed = false;
|
||||
surface.set_lasers_anytime(enabled);
|
||||
save_failed = persist_mount && !save_lasers_anytime(laser_settings_path, enabled);
|
||||
persistence.save_failed = persistence.persist_mount && !save_lasers_anytime(persistence.laser_settings_path, enabled);
|
||||
} else {
|
||||
laser_change_failed = true;
|
||||
persistence.laser_change_failed = true;
|
||||
}
|
||||
}
|
||||
if (event_result.mount) {
|
||||
mount = *event_result.mount;
|
||||
save_failed = persist_mount && !save_mount(settings_path, mount);
|
||||
persistence.save_failed = persistence.persist_mount && !save_mount(persistence.settings_path, mount);
|
||||
world_ready = false;
|
||||
applied_mount.reset();
|
||||
}
|
||||
@@ -553,7 +575,7 @@ struct Overlay::Impl {
|
||||
}
|
||||
update_drag();
|
||||
place();
|
||||
if (drag.active()) {
|
||||
if (dragging.panel.active()) {
|
||||
// A controller used to manipulate the panel must not also authorize
|
||||
// Record/Insert/Enter. Require neutral rearm after the drag ends.
|
||||
left.reset(); right.reset();
|
||||
@@ -569,8 +591,8 @@ struct Overlay::Impl {
|
||||
// Keep requesting it across dashboard/laser states: those are what the
|
||||
// experiment compares. SteamVR's separate permission gate is never changed here.
|
||||
set.nPriority = action_priority();
|
||||
action_update_error = input->UpdateActionState(&set, sizeof(set), 1);
|
||||
if (action_update_error != vr::VRInputError_None) {
|
||||
diagnostics.action_update_error = input->UpdateActionState(&set, sizeof(set), 1);
|
||||
if (diagnostics.action_update_error != vr::VRInputError_None) {
|
||||
reset_input(result); return result;
|
||||
}
|
||||
auto [la, ld] = digital(0);
|
||||
@@ -608,15 +630,21 @@ Overlay::Overlay(const std::string& assets, const std::string& font, std::option
|
||||
: impl_(std::make_unique<Impl>(assets, font, mount, persist_mount)) {}
|
||||
Overlay::~Overlay() = default;
|
||||
std::vector<UiAction> Overlay::poll() { return impl_->poll(); }
|
||||
std::vector<ModelAction> Overlay::take_model_actions() {
|
||||
auto result = std::move(impl_->model_actions);
|
||||
impl_->model_actions.clear();
|
||||
return result;
|
||||
}
|
||||
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
|
||||
bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; }
|
||||
bool Overlay::auto_insert() const { return impl_->config.auto_insert; }
|
||||
bool Overlay::close_mic_when_idle() const { return impl_->config.close_mic_when_idle; }
|
||||
const std::vector<std::string>& Overlay::quick_inputs() const { return impl_->config.quick_inputs; }
|
||||
std::string Overlay::controls_status() {
|
||||
// Compare the same actions across modes, before and after our pose/role gate.
|
||||
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
|
||||
std::string result = impl_->input_mode_status() +
|
||||
" update=" + std::to_string(int(impl_->action_update_error));
|
||||
" update=" + std::to_string(int(impl_->diagnostics.action_update_error));
|
||||
for (size_t i = 0; i < action_names.size(); ++i) {
|
||||
vr::InputDigitalActionData_t data{};
|
||||
const auto error = impl_->input->GetDigitalActionData(impl_->actions[i], &data, sizeof(data), vr::k_ulInvalidInputValueHandle);
|
||||
@@ -629,18 +657,18 @@ std::string Overlay::controls_status() {
|
||||
return result;
|
||||
}
|
||||
std::string Overlay::pointer_status() const {
|
||||
return "Pointer down=" + std::to_string(impl_->pointer_downs) + " up=" + std::to_string(impl_->pointer_ups) +
|
||||
" hits=" + std::to_string(impl_->pointer_actions) + " resets=" + std::to_string(impl_->pointer_resets) +
|
||||
" last=" + impl_->last_pointer_event +
|
||||
"\nOverlay renderer=Vulkan textureUploads=" + std::to_string(impl_->texture_uploads) +
|
||||
" showCalls=" + std::to_string(impl_->show_calls) + " hideCalls=" + std::to_string(impl_->hide_calls) +
|
||||
" shownEvents=" + std::to_string(impl_->overlay_shown_events) +
|
||||
" hiddenEvents=" + std::to_string(impl_->overlay_hidden_events) +
|
||||
" imageLoaded=" + std::to_string(impl_->image_loaded_events) +
|
||||
" imageFailed=" + std::to_string(impl_->image_failed_events) +
|
||||
" overlayFocus=" + std::to_string(impl_->overlay_focus_events) +
|
||||
" globalFocus=" + std::to_string(impl_->global_focus_events) +
|
||||
" inputCaptured=" + std::to_string(impl_->input_focus_captured_events);
|
||||
return "Pointer down=" + std::to_string(impl_->diagnostics.pointer_downs) + " up=" + std::to_string(impl_->diagnostics.pointer_ups) +
|
||||
" hits=" + std::to_string(impl_->diagnostics.pointer_actions) + " resets=" + std::to_string(impl_->diagnostics.pointer_resets) +
|
||||
" last=" + impl_->diagnostics.last_pointer_event +
|
||||
"\nOverlay renderer=Vulkan textureUploads=" + std::to_string(impl_->diagnostics.texture_uploads) +
|
||||
" showCalls=" + std::to_string(impl_->diagnostics.show_calls) + " hideCalls=" + std::to_string(impl_->diagnostics.hide_calls) +
|
||||
" shownEvents=" + std::to_string(impl_->diagnostics.overlay_shown_events) +
|
||||
" hiddenEvents=" + std::to_string(impl_->diagnostics.overlay_hidden_events) +
|
||||
" imageLoaded=" + std::to_string(impl_->diagnostics.image_loaded_events) +
|
||||
" imageFailed=" + std::to_string(impl_->diagnostics.image_failed_events) +
|
||||
" overlayFocus=" + std::to_string(impl_->diagnostics.overlay_focus_events) +
|
||||
" globalFocus=" + std::to_string(impl_->diagnostics.global_focus_events) +
|
||||
" inputCaptured=" + std::to_string(impl_->diagnostics.input_focus_captured_events);
|
||||
}
|
||||
|
||||
int registration(const std::string& manifest, bool remove, bool autostart) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "mount.hpp"
|
||||
#include <optional>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -8,6 +9,14 @@
|
||||
|
||||
namespace frameyap {
|
||||
enum class UiAction { Toggle, Record, BeginRecord, EndRecord, Cancel, Insert, Enter, QuickChat, Quit };
|
||||
struct ModelOption {
|
||||
std::string id, name, state, source, license, license_text, attribution;
|
||||
uint64_t bytes = 0;
|
||||
bool verified = false;
|
||||
std::string manifest_sha256;
|
||||
bool operator==(const ModelOption&) const = default;
|
||||
};
|
||||
struct ModelAction { std::string id; bool install = false; std::string manifest_sha256; };
|
||||
struct Panel {
|
||||
Panel() = default;
|
||||
Panel(std::string status, std::string transcript, std::string detail,
|
||||
@@ -23,6 +32,9 @@ struct Panel {
|
||||
bool quick_open = false;
|
||||
size_t quick_selected = 0;
|
||||
std::vector<std::string> quick_inputs;
|
||||
std::vector<ModelOption> models;
|
||||
std::string selected_backend, model_note;
|
||||
bool model_busy = false;
|
||||
};
|
||||
class Overlay {
|
||||
public:
|
||||
@@ -32,9 +44,11 @@ public:
|
||||
Overlay(const Overlay&) = delete;
|
||||
Overlay& operator=(const Overlay&) = delete;
|
||||
std::vector<UiAction> poll();
|
||||
std::vector<ModelAction> take_model_actions();
|
||||
void draw(const Panel& panel);
|
||||
bool advanced_debug() const;
|
||||
bool auto_insert() const;
|
||||
bool close_mic_when_idle() const;
|
||||
const std::vector<std::string>& quick_inputs() const;
|
||||
std::string controls_status(); // diagnostic only, no input delivery
|
||||
std::string pointer_status() const; // diagnostic counters, no input delivery
|
||||
|
||||
+184
-30
@@ -30,10 +30,11 @@ struct Rect {
|
||||
};
|
||||
enum class Control { Review, Settings, Bindings, Prev, Next, Record, Cancel, Insert, Enter, Quit,
|
||||
World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert,
|
||||
Clock24h, Date, LockLayout };
|
||||
enum class Tab { Review, Settings };
|
||||
Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow,
|
||||
ModelInstall, ModelPrev, ModelNext };
|
||||
enum class Tab { Review, Settings, Models };
|
||||
struct Button { Rect r; Control id; const char* label; };
|
||||
constexpr std::array<Button, 21> buttons{{
|
||||
constexpr std::array<Button, 32> buttons{{
|
||||
{{32, 138, 180, 46}, Control::Review, "Review"},
|
||||
{{226, 138, 180, 46}, Control::Settings, "Settings"},
|
||||
{{420, 138, 180, 46}, Control::Bindings, "Bindings"},
|
||||
@@ -42,19 +43,30 @@ constexpr std::array<Button, 21> buttons{{
|
||||
{{838, 406, 130, 44}, Control::Next, "Next"},
|
||||
{{32, 574, 176, 68}, Control::Record, "Record"},
|
||||
{{222, 574, 176, 68}, Control::Cancel, "Cancel"},
|
||||
{{412, 574, 176, 68}, Control::Insert, "Insert"},
|
||||
{{602, 574, 176, 68}, Control::Enter, "Submit"},
|
||||
{{412, 574, 176, 68}, Control::Insert, "Type"},
|
||||
{{602, 574, 176, 68}, Control::Enter, "Type + Enter"},
|
||||
{{792, 574, 176, 68}, Control::Quit, "Hold Quit"},
|
||||
{{32, 234, 454, 58}, Control::World, "World space"},
|
||||
{{514, 234, 454, 58}, Control::Head, "Head"},
|
||||
{{32, 308, 454, 58}, Control::Left, "Left wrist"},
|
||||
{{514, 308, 454, 58}, Control::Right, "Right wrist"},
|
||||
{{32, 394, 300, 50}, Control::Recenter, "Recenter in front"},
|
||||
{{514, 394, 454, 50}, Control::LasersAnytime, "Lasers anytime"},
|
||||
{{32, 452, 454, 48}, Control::AutoInsert, "Auto insert"},
|
||||
{{514, 452, 454, 48}, Control::AdvancedDebug, "Advanced debug"},
|
||||
{{32, 506, 454, 42}, Control::Clock24h, "Clock"},
|
||||
{{514, 506, 454, 42}, Control::Date, "Date"},
|
||||
{{32, 378, 300, 44}, Control::Recenter, "Recenter in front"},
|
||||
{{514, 378, 454, 44}, Control::LasersAnytime, "Lasers anytime"},
|
||||
{{32, 428, 454, 40}, Control::AutoInsert, "Auto insert"},
|
||||
{{514, 428, 454, 40}, Control::AdvancedDebug, "Advanced debug"},
|
||||
{{32, 474, 454, 36}, Control::Clock24h, "Clock"},
|
||||
{{514, 474, 454, 36}, Control::Date, "Date"},
|
||||
{{32, 516, 454, 32}, Control::CloseMicWhenIdle, "Close mic when idle"},
|
||||
{{514, 516, 454, 32}, Control::Models, "Models / backends"},
|
||||
{{32, 242, 936, 42}, Control::ModelRow, ""},
|
||||
{{32, 290, 936, 42}, Control::ModelRow, ""},
|
||||
{{32, 338, 936, 42}, Control::ModelRow, ""},
|
||||
{{32, 386, 936, 42}, Control::ModelRow, ""},
|
||||
{{32, 434, 936, 42}, Control::ModelRow, ""},
|
||||
{{32, 482, 936, 42}, Control::ModelRow, ""},
|
||||
{{514, 530, 454, 36}, Control::ModelInstall, "Install"},
|
||||
{{32, 530, 214, 36}, Control::ModelPrev, "Previous"},
|
||||
{{260, 530, 214, 36}, Control::ModelNext, "Next"},
|
||||
}};
|
||||
std::optional<UiAction> action(Control c) {
|
||||
switch (c) {
|
||||
@@ -105,6 +117,7 @@ struct PanelSurface::Impl {
|
||||
Color background, card, ink, muted, cyan, pink;
|
||||
Tab tab = Tab::Review;
|
||||
bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false;
|
||||
bool close_mic_when_idle = false;
|
||||
bool clock_24h = false, layout_locked = false;
|
||||
DateFormat date_format = DateFormat::MonthDayYear;
|
||||
std::time_t clock_time = std::time(nullptr);
|
||||
@@ -116,7 +129,41 @@ struct PanelSurface::Impl {
|
||||
int drag_cursor = -1;
|
||||
std::vector<std::string> lines;
|
||||
size_t page = 0;
|
||||
size_t model_page = 0;
|
||||
size_t consent_page = 0;
|
||||
size_t consent_rendered_page = size_t(-1);
|
||||
bool install_confirm = false;
|
||||
std::optional<ModelOption> consent_snapshot;
|
||||
std::vector<std::string> consent_lines;
|
||||
static constexpr size_t lines_per_page = 4;
|
||||
static constexpr size_t consent_lines_per_page = 6;
|
||||
size_t consent_pages() const {
|
||||
return std::max(size_t(1), (consent_lines.size() + consent_lines_per_page - 1) / consent_lines_per_page);
|
||||
}
|
||||
std::vector<std::string> visible_consent_lines() const {
|
||||
if (!install_confirm || !consent_snapshot || consent_page != consent_rendered_page) return {};
|
||||
auto first = consent_page * consent_lines_per_page;
|
||||
auto last = std::min(consent_lines.size(), first + consent_lines_per_page);
|
||||
return {consent_lines.begin() + first, consent_lines.begin() + last};
|
||||
}
|
||||
void prepare_consent(const ModelOption& m) {
|
||||
consent_snapshot = m;
|
||||
consent_lines.clear();
|
||||
// Each field is independently wrapped; no hidden tail of the source,
|
||||
// license or attribution may be mistaken for reviewed consent.
|
||||
for (const auto& field : {"Backend: " + m.name + " (" + m.id + ")",
|
||||
"Download bytes: " + std::to_string(m.bytes),
|
||||
"Source: " + m.source, "License: " + m.license,
|
||||
"License text: " + m.license_text,
|
||||
"Attribution: " + m.attribution,
|
||||
"Manifest SHA-256: " + m.manifest_sha256}) {
|
||||
auto rows = wrap(field, 19, 890);
|
||||
consent_lines.insert(consent_lines.end(), rows.begin(), rows.end());
|
||||
}
|
||||
consent_page = 0;
|
||||
consent_rendered_page = size_t(-1);
|
||||
install_confirm = true;
|
||||
}
|
||||
|
||||
Impl(const std::string& font, Mount m, Theme t)
|
||||
: mount(m), theme(t), background(t.background), card(t.card), ink(t.ink), muted(t.muted),
|
||||
@@ -247,19 +294,24 @@ struct PanelSurface::Impl {
|
||||
}
|
||||
size_t page_count() const { return std::max(size_t(1), (lines.size() + lines_per_page - 1) / lines_per_page); }
|
||||
bool available(UiAction a) const {
|
||||
if (a == UiAction::Record) return !panel.quick_open && (panel.recording || panel.record_available);
|
||||
if (a == UiAction::Insert) return !panel.quick_open && panel.enabled && !panel.recording && !panel.transcript.empty();
|
||||
if (a == UiAction::Enter) return panel.enabled && !panel.recording;
|
||||
if (a == UiAction::QuickChat) return panel.enabled && !panel.recording && !panel.quick_inputs.empty();
|
||||
if (a == UiAction::Record) return tab != Tab::Models && !panel.quick_open && (panel.recording || panel.record_available);
|
||||
if (a == UiAction::Insert) return tab != Tab::Models && !panel.quick_open && panel.enabled && !panel.recording && !panel.transcript.empty();
|
||||
if (a == UiAction::Enter) return tab != Tab::Models && panel.enabled && !panel.recording;
|
||||
if (a == UiAction::QuickChat) return tab != Tab::Models && panel.enabled && !panel.recording && !panel.quick_inputs.empty();
|
||||
if (a == UiAction::Toggle) return false;
|
||||
return true;
|
||||
}
|
||||
bool visible(Control c) const {
|
||||
if (panel.quick_open && c != Control::Cancel && c != Control::Enter && c != Control::Quit) return false;
|
||||
if (c == Control::Models) return tab == Tab::Settings;
|
||||
if (c == Control::ModelRow) return tab == Tab::Models && !install_confirm;
|
||||
if (c == Control::ModelPrev || c == Control::ModelNext) return tab == Tab::Models;
|
||||
if (c == Control::ModelInstall) return tab == Tab::Models;
|
||||
if (c == Control::Prev || c == Control::Next) return tab == Tab::Review && !panel.quick_open;
|
||||
if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime ||
|
||||
c == Control::AdvancedDebug || c == Control::AutoInsert ||
|
||||
c == Control::Clock24h || c == Control::Date || c == Control::LockLayout) return tab == Tab::Settings;
|
||||
c == Control::Clock24h || c == Control::Date || c == Control::LockLayout ||
|
||||
c == Control::CloseMicWhenIdle) return tab == Tab::Settings;
|
||||
return true;
|
||||
}
|
||||
bool enabled(Control c) const {
|
||||
@@ -267,11 +319,25 @@ struct PanelSurface::Impl {
|
||||
if (c == Control::Prev) return page > 0;
|
||||
if (c == Control::Next) return page + 1 < page_count();
|
||||
if (c == Control::Recenter) return mount == Mount::World;
|
||||
if (c == Control::ModelRow) return !panel.model_busy;
|
||||
if (c == Control::ModelInstall) {
|
||||
if (panel.model_busy) return false;
|
||||
auto it = std::find_if(panel.models.begin(), panel.models.end(), [&](const auto& m) { return m.id == panel.selected_backend; });
|
||||
return it != panel.models.end() && !it->verified &&
|
||||
(!install_confirm || (consent_snapshot == *it && consent_page == consent_rendered_page &&
|
||||
consent_page + 1 == consent_pages()));
|
||||
}
|
||||
if (c == Control::ModelPrev) return install_confirm ? consent_page > 0 : model_page > 0;
|
||||
if (c == Control::ModelNext) return install_confirm ?
|
||||
consent_page == consent_rendered_page && consent_page + 1 < consent_pages() :
|
||||
(model_page + 1) * 6 < panel.models.size();
|
||||
return true;
|
||||
}
|
||||
int hit(float x, float y) const {
|
||||
for (size_t i = 0; i < buttons.size(); ++i)
|
||||
for (size_t i = 0; i < buttons.size(); ++i) {
|
||||
if (buttons[i].id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue;
|
||||
if (visible(buttons[i].id) && enabled(buttons[i].id) && buttons[i].r.contains(x, y)) return int(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
void reset() {
|
||||
@@ -285,13 +351,18 @@ struct PanelSurface::Impl {
|
||||
dirty = true;
|
||||
}
|
||||
if (p.transcript != panel.transcript || lines.empty()) {
|
||||
lines = wrap(p.transcript.empty() ? "Your words will appear here.\nReview them, then choose Insert." : p.transcript, 32, 904);
|
||||
lines = wrap(p.transcript.empty() ? "Your words will appear here.\nReview them, then choose Type." : p.transcript, 32, 904);
|
||||
page = 0; dirty = true;
|
||||
}
|
||||
if (p.status != panel.status || p.enabled != panel.enabled || p.recording != panel.recording ||
|
||||
p.quick_open != panel.quick_open || p.quick_selected != panel.quick_selected || p.quick_inputs != panel.quick_inputs)
|
||||
p.quick_open != panel.quick_open || p.quick_selected != panel.quick_selected || p.quick_inputs != panel.quick_inputs ||
|
||||
p.models != panel.models || p.selected_backend != panel.selected_backend ||
|
||||
p.model_note != panel.model_note || p.model_busy != panel.model_busy)
|
||||
dirty = true;
|
||||
if (p.quick_open != panel.quick_open) reset();
|
||||
if (p.quick_open != panel.quick_open || p.models != panel.models ||
|
||||
p.selected_backend != panel.selected_backend || p.model_busy != panel.model_busy) {
|
||||
reset(); install_confirm = false; consent_snapshot.reset();
|
||||
}
|
||||
panel = p;
|
||||
if (panel.quick_open && tab != Tab::Review) { tab = Tab::Review; dirty = true; }
|
||||
int progress = 0;
|
||||
@@ -320,7 +391,7 @@ struct PanelSurface::Impl {
|
||||
if (status.size() > 1) text("[...]", 864, 109, 23, muted, 968);
|
||||
if (tab == Tab::Review && panel.quick_open) {
|
||||
rounded({32, 212, 936, 324}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f);
|
||||
text("QUICK CHAT Y: NEXT SUBMIT: SEND CANCEL: CLOSE", 48, 243, 22, cyan, 952);
|
||||
text("QUICK PHRASES Y: NEXT TYPE + ENTER: CHOICE + ENTER CANCEL: CLOSE", 48, 243, 21, cyan, 952);
|
||||
for (size_t i = 0; i < panel.quick_inputs.size() && i < 6; ++i) {
|
||||
const Rect row{48, 254 + int(i) * 45, 904, 40};
|
||||
const bool selected = i == panel.quick_selected;
|
||||
@@ -334,23 +405,45 @@ struct PanelSurface::Impl {
|
||||
text(lines[page * lines_per_page + i], 48, 246 + int(i) * 40, 32,
|
||||
panel.transcript.empty() ? muted : ink, 952);
|
||||
text("PAGE " + std::to_string(page + 1) + " / " + std::to_string(page_count()), 416, 436, 23, muted, 790);
|
||||
if (!binding_note.empty()) text(binding_note, 32, 203, 20, muted, 968);
|
||||
text(binding_note.empty() ? "Type adds a space; Type + Enter explicitly submits." : binding_note,
|
||||
32, 203, 20, muted, 968);
|
||||
} else if (tab == Tab::Models) {
|
||||
text("Models: select to restart. Install always needs explicit confirmation.", 32, 202, 19, muted, 968);
|
||||
const auto it = std::find_if(panel.models.begin(), panel.models.end(), [&](const auto& m) { return m.id == panel.selected_backend; });
|
||||
if (it != panel.models.end()) {
|
||||
if (install_confirm && consent_snapshot == *it) {
|
||||
text("Review every page before Confirm Install. Settings cancels.", 32, 225, 19, pink, 968);
|
||||
// Only a painted page can advance review or authorize the
|
||||
// install: multiple pointer events between draws cannot skip it.
|
||||
consent_rendered_page = consent_page;
|
||||
const auto rows = visible_consent_lines();
|
||||
for (size_t i = 0; i < rows.size(); ++i)
|
||||
text(rows[i], 48, 271 + int(i) * 34, 19, ink, 950);
|
||||
text("METADATA PAGE " + std::to_string(consent_page + 1) + " / " + std::to_string(consent_pages()),
|
||||
32, 505, 19, cyan, 968);
|
||||
} else {
|
||||
text("Select Install to review full source, size and license before download.", 32, 225, 18, cyan, 968);
|
||||
}
|
||||
} else text("No model selected.", 32, 225, 18, pink, 968);
|
||||
} else {
|
||||
text("Auto insert needs stable X focus; debug logs may contain speech.", 32, 204, 18, pink, 968);
|
||||
text(placement_note.empty() ? "No automatic Enter; changing debug restarts the worker." : placement_note,
|
||||
32, 225, 18, muted, 968);
|
||||
text("Hold Quit: hold 0.9s then release. Lasers anytime: system-wide; may affect games.",
|
||||
32, 204, 18, pink, 968);
|
||||
text(placement_note.empty() ? "Auto insert needs stable X focus; debug logs may contain speech." : placement_note,
|
||||
32, 225, 17, muted, 968);
|
||||
}
|
||||
rect({32, 550, 936, 1}, mix(card, cyan, .17f));
|
||||
for (size_t i = 0; i < buttons.size(); ++i) {
|
||||
const auto& b = buttons[i];
|
||||
if (!visible(b.id)) continue;
|
||||
bool on = enabled(b.id);
|
||||
if (b.id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue;
|
||||
bool selected = (b.id == Control::Review && tab == Tab::Review) ||
|
||||
(b.id == Control::Settings && tab == Tab::Settings) ||
|
||||
(mounting(b.id) && *mounting(b.id) == mount) ||
|
||||
(b.id == Control::LasersAnytime && lasers_anytime) ||
|
||||
(b.id == Control::AdvancedDebug && advanced_debug) ||
|
||||
(b.id == Control::AutoInsert && auto_insert) ||
|
||||
(b.id == Control::CloseMicWhenIdle && close_mic_when_idle) ||
|
||||
(b.id == Control::LockLayout && layout_locked);
|
||||
const Color fill = !on ? mix(background, card, .40f) :
|
||||
selected ? mix(card, cyan, .14f) : card;
|
||||
@@ -366,23 +459,41 @@ struct PanelSurface::Impl {
|
||||
PanelSurface::Clock::now() - press_time[cursor]).count()) / PanelSurface::quit_hold.count(), 0.f, 1.f);
|
||||
rect({b.r.x + 7, b.r.y + b.r.h - 9, int((b.r.w - 14) * fraction), 3}, pink);
|
||||
}
|
||||
const std::string label = b.id == Control::Record && panel.recording ? "Stop" :
|
||||
const std::string label = b.id == Control::ModelRow ? [&]() {
|
||||
const auto& m = panel.models[model_page * 6 + i - 23];
|
||||
return (m.id == panel.selected_backend ? "[ACTIVE] " : "") + m.name + " - " +
|
||||
(m.id == panel.selected_backend && panel.model_busy ? "checking" : m.state);
|
||||
}() : b.id == Control::ModelInstall && install_confirm ? "Confirm Install" :
|
||||
b.id == Control::Record && panel.recording ? "Stop" :
|
||||
b.id == Control::Clock24h ? (clock_24h ? "Clock: 24 hour" : "Clock: 12 hour") :
|
||||
b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" :
|
||||
date_format == DateFormat::MonthDayYear ? "Date: MM/DD/YYYY" :
|
||||
date_format == DateFormat::DayMonthYear ? "Date: DD/MM/YYYY" : "Date: YYYY-MM-DD") : b.label;
|
||||
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, b.id == Control::Date ? 23 : 27,
|
||||
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9,
|
||||
b.id == Control::Date ? 23 : b.id == Control::CloseMicWhenIdle ? 22 :
|
||||
b.id == Control::Enter ? 20 : 27,
|
||||
on ? ink : mix(background, muted, .48f), b.r.x + b.r.w - 8);
|
||||
if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12);
|
||||
if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug || b.id == Control::AutoInsert ||
|
||||
b.id == Control::LockLayout) {
|
||||
b.id == Control::LockLayout || b.id == Control::CloseMicWhenIdle) {
|
||||
bool active = b.id == Control::LasersAnytime ? lasers_anytime :
|
||||
b.id == Control::AutoInsert ? auto_insert :
|
||||
b.id == Control::LockLayout ? layout_locked : advanced_debug;
|
||||
b.id == Control::LockLayout ? layout_locked :
|
||||
b.id == Control::CloseMicWhenIdle ? close_mic_when_idle : advanced_debug;
|
||||
text(active ? "ON" : "OFF", b.r.x + b.r.w - 66, b.r.y + b.r.h / 2 + 9, 22,
|
||||
active ? cyan : muted, b.r.x + b.r.w - 12);
|
||||
}
|
||||
}
|
||||
if (tab == Tab::Settings)
|
||||
text("OFF: discard idle audio; ON: spike / start latency.", 390, 565, 16, muted, 887);
|
||||
if (tab == Tab::Models && !panel.model_note.empty()) {
|
||||
// The install/navigation controls occupy y=530..566; the status
|
||||
// belongs BELOW the shared footer (574..642), not under buttons.
|
||||
auto rows = wrap(panel.model_note, 15, 904);
|
||||
for (size_t i = 0; i < rows.size() && i < 2; ++i)
|
||||
text(rows[i], 32, 658 + int(i) * 18, 15, pink, 968);
|
||||
if (rows.size() > 2) text("[note shortened]", 805, 677, 13, pink, 968);
|
||||
}
|
||||
// Screenshot-inspired grab underline and outside corner bracket. No
|
||||
// opaque toolbar backing; broad hit targets surround the slender strokes.
|
||||
if (!layout_locked) {
|
||||
@@ -411,6 +522,9 @@ PanelSurface::~PanelSurface() = default;
|
||||
bool PanelSurface::render(const Panel& p) { return impl_->render(p); }
|
||||
const std::vector<unsigned char>& PanelSurface::pixels() const { return impl_->pixels; }
|
||||
bool PanelSurface::available(UiAction a) const { return impl_->available(a); }
|
||||
std::vector<std::string> PanelSurface::visible_model_review_lines() const {
|
||||
return impl_->visible_consent_lines();
|
||||
}
|
||||
bool PanelSurface::dragging(unsigned cursor) const {
|
||||
return cursor < impl_->pressed.size() && int(cursor) == impl_->drag_cursor;
|
||||
}
|
||||
@@ -446,13 +560,46 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::
|
||||
else if (c == Control::LasersAnytime) result.lasers_anytime = !impl_->lasers_anytime;
|
||||
else if (c == Control::AdvancedDebug) result.advanced_debug = !impl_->advanced_debug;
|
||||
else if (c == Control::AutoInsert) result.auto_insert = !impl_->auto_insert;
|
||||
else if (c == Control::CloseMicWhenIdle) result.close_mic_when_idle = !impl_->close_mic_when_idle;
|
||||
else if (c == Control::LockLayout) result.lock_layout = !impl_->layout_locked;
|
||||
else if (c == Control::Clock24h) result.clock_24h = !impl_->clock_24h;
|
||||
else if (c == Control::Date) result.date_format = static_cast<DateFormat>((static_cast<int>(impl_->date_format) + 1) % 4);
|
||||
else if (c == Control::Bindings) { result.open_bindings = true; impl_->reset(); }
|
||||
else if (c == Control::Models) {
|
||||
impl_->tab = Tab::Models; impl_->model_page = 0;
|
||||
impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::ModelRow) {
|
||||
const auto& model = impl_->panel.models[impl_->model_page * 6 + index - 23];
|
||||
result.model_action = ModelAction{model.id, false, {}};
|
||||
impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::ModelInstall) {
|
||||
auto it = std::find_if(impl_->panel.models.begin(), impl_->panel.models.end(), [&](const auto& m) {
|
||||
return m.id == impl_->panel.selected_backend;
|
||||
});
|
||||
if (it != impl_->panel.models.end() && !it->verified && !impl_->panel.model_busy &&
|
||||
it->manifest_sha256.size() == 64) {
|
||||
if (impl_->install_confirm && impl_->consent_snapshot == *it)
|
||||
result.model_action = ModelAction{it->id, true, impl_->consent_snapshot->manifest_sha256};
|
||||
else if (!impl_->install_confirm) impl_->prepare_consent(*it);
|
||||
}
|
||||
if (result.model_action) { impl_->install_confirm = false; impl_->consent_snapshot.reset(); }
|
||||
impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::ModelPrev) {
|
||||
if (impl_->install_confirm) --impl_->consent_page;
|
||||
else --impl_->model_page;
|
||||
impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::ModelNext) {
|
||||
if (impl_->install_confirm) ++impl_->consent_page;
|
||||
else ++impl_->model_page;
|
||||
impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::Review || c == Control::Settings) {
|
||||
impl_->tab = c == Control::Review ? Tab::Review : Tab::Settings;
|
||||
impl_->reset(); impl_->dirty = true;
|
||||
impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::Prev) { --impl_->page; impl_->dirty = true; }
|
||||
else if (c == Control::Next) { ++impl_->page; impl_->dirty = true; }
|
||||
@@ -496,6 +643,13 @@ void PanelSurface::set_auto_insert(bool enabled) {
|
||||
impl_->dirty = true;
|
||||
}
|
||||
}
|
||||
void PanelSurface::set_close_mic_when_idle(bool enabled) {
|
||||
if (impl_->close_mic_when_idle != enabled) {
|
||||
impl_->close_mic_when_idle = enabled;
|
||||
impl_->reset();
|
||||
impl_->dirty = true;
|
||||
}
|
||||
}
|
||||
void PanelSurface::set_clock_24h(bool enabled) {
|
||||
if (impl_->clock_24h != enabled) {
|
||||
impl_->clock_24h = enabled; impl_->reset(); impl_->dirty = true;
|
||||
|
||||
@@ -16,11 +16,13 @@ struct SurfaceEvent {
|
||||
std::optional<bool> lasers_anytime;
|
||||
std::optional<bool> advanced_debug;
|
||||
std::optional<bool> auto_insert;
|
||||
std::optional<bool> close_mic_when_idle;
|
||||
std::optional<bool> lock_layout;
|
||||
std::optional<bool> clock_24h;
|
||||
std::optional<DateFormat> date_format;
|
||||
bool recenter = false;
|
||||
bool open_bindings = false;
|
||||
std::optional<ModelAction> model_action;
|
||||
};
|
||||
// One CPU RGBA canvas, independent of OpenVR. Settings replace the review area;
|
||||
// status and safety controls remain on the same surface.
|
||||
@@ -51,11 +53,15 @@ public:
|
||||
void set_lasers_anytime(bool enabled);
|
||||
void set_advanced_debug(bool enabled);
|
||||
void set_auto_insert(bool enabled);
|
||||
void set_close_mic_when_idle(bool enabled);
|
||||
void set_clock_24h(bool enabled);
|
||||
void set_date_format(DateFormat format);
|
||||
void set_clock_time(std::time_t now);
|
||||
void set_binding_note(std::string note);
|
||||
bool available(UiAction action) const;
|
||||
// Exact text rows currently shown in the consent viewport (empty outside it).
|
||||
// OpenVR has no screen-reader accessibility channel for this canvas.
|
||||
std::vector<std::string> visible_model_review_lines() const;
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "backend_manager.hpp"
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace frameyap;
|
||||
namespace {
|
||||
const std::string digest(64, 'a');
|
||||
void put(const std::filesystem::path& file, const std::string& contents) {
|
||||
std::ofstream out(file); out << contents;
|
||||
}
|
||||
void until_idle(BackendManager& manager) {
|
||||
for (int n = 0; n < 500 && manager.busy(); ++n) {
|
||||
manager.poll(); std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
}
|
||||
assert(!manager.busy());
|
||||
}
|
||||
std::string status() {
|
||||
return "print('ST\\tfake\\tFake%20backend\\tnot_installed\\tmissing_files\\t7\\thttps%3A%2F%2Fexample.org\\tMIT\\tTest%20license\\tFixture\\t/tmp/fake\\t" + digest + "', flush=True)\n"
|
||||
" print('DONE', flush=True)\n";
|
||||
}
|
||||
}
|
||||
int main() {
|
||||
auto path = std::filesystem::temp_directory_path() / ("frameyap-backend-test-" + std::to_string(getpid()));
|
||||
std::filesystem::create_directory(path);
|
||||
auto service = path / "service.py";
|
||||
put(service, "import sys\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n assert sys.argv[sys.argv.index('--expected-manifest-sha256') + 1] == '" + digest + "'\n"
|
||||
" print('{\"ok\": true, \"event\": \"complete\"}', flush=True)\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
assert(!manager.busy());
|
||||
manager.refresh(); assert(manager.busy());
|
||||
until_idle(manager);
|
||||
assert(manager.entries().size() == 1 && manager.entries()[0].id == "fake");
|
||||
assert(manager.entries()[0].bytes == 7 && manager.entries()[0].name == "Fake backend");
|
||||
assert(manager.entries()[0].state == "not_installed" && manager.checked());
|
||||
assert(manager.entries()[0].manifest_sha256 == digest);
|
||||
assert(manager.revision() == 1 && manager.model_path("fake") == "/tmp/fake");
|
||||
try { manager.install("unknown", digest); assert(false); } catch (const std::runtime_error&) {}
|
||||
try { manager.install("fake", std::string(64, 'b')); assert(false); } catch (const std::runtime_error&) {}
|
||||
assert(!manager.busy()); // mismatched consent cannot launch a helper
|
||||
manager.install("fake", digest);
|
||||
assert(manager.installing());
|
||||
until_idle(manager); // success must trigger a fresh status check
|
||||
assert(manager.revision() == 2 && manager.checked());
|
||||
manager.refresh(); manager.cancel(); assert(!manager.busy());
|
||||
}
|
||||
// Installer output larger than one poll budget may remain after helper exit.
|
||||
// Success still requires EOF followed by a fresh offline status check.
|
||||
put(service, "import os, sys\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n assert os.get_blocking(1), 'child stdout must be blocking'\n"
|
||||
" for _ in range(3000): print('{\"event\":\"complete\",\"ok\":true}')\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager); assert(manager.checked());
|
||||
manager.install("fake", digest); until_idle(manager);
|
||||
assert(manager.checked() && manager.revision() == 2);
|
||||
}
|
||||
// A fast-exiting helper can leave output behind. Read through EOF, then
|
||||
// reject any trailing record after DONE, never accept a success substring.
|
||||
put(service, "import sys\nif True:\n " + status() + "print('trailing', flush=True)\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager);
|
||||
assert(!manager.checked() && manager.note() == "Local model check failed.");
|
||||
}
|
||||
put(service, "import sys\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n print('{\"event\":\"complete\",\"ok\":true}')\n raise SystemExit(1)\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager); assert(manager.checked());
|
||||
manager.install("fake", digest); until_idle(manager);
|
||||
assert(manager.checked() && manager.revision() == 3); // failure plus offline recheck
|
||||
assert(manager.note() == "Install failed: installer exited unsuccessfully; local model status checked offline.");
|
||||
manager.refresh(); until_idle(manager);
|
||||
assert(manager.note() == "Install failed: installer exited unsuccessfully; local model status checked offline.");
|
||||
}
|
||||
// JSON is display-only. Per-file progress appears before process exit and
|
||||
// a structured error survives rechecks without echoing terminal controls.
|
||||
put(service, "import sys, time\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n print('{\"ok\":true,\"event\":\"model_file\",\"file\":\"weights.dat\",\"state\":\"downloading\",\"bytes\":7}', flush=True)\n"
|
||||
" time.sleep(.2)\n"
|
||||
" print('{\"ok\":true,\"event\":\"model_file\",\"file\":\"weights.dat\",\"state\":\"verified\"}', flush=True)\n"
|
||||
" time.sleep(.2)\n"
|
||||
" print('{\"ok\":false,\"code\":\"manifest_mismatch\",\"message\":\"metadata changed \\\\u001b[31m\"}', flush=True)\n"
|
||||
" raise SystemExit(1)\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager); manager.install("fake", digest);
|
||||
bool downloading = false, verified = false;
|
||||
for (int n = 0; n < 500 && manager.busy(); ++n) {
|
||||
manager.poll();
|
||||
downloading |= manager.note() == "Downloading: weights.dat";
|
||||
verified |= manager.note() == "Verified file: weights.dat";
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
}
|
||||
assert(downloading && verified && manager.checked());
|
||||
assert(manager.note().find("manifest_mismatch: metadata changed ?[31m") != std::string::npos);
|
||||
manager.refresh();
|
||||
assert(manager.note().find("metadata changed") != std::string::npos); // during recheck
|
||||
until_idle(manager);
|
||||
assert(manager.note().find("metadata changed") != std::string::npos);
|
||||
}
|
||||
// A lying success event, unknown event, and malformed object never certify
|
||||
// installation: exit status and the fresh offline check still decide.
|
||||
put(service, "import sys\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n print('{\"ok\":true,\"event\":\"complete\"}', flush=True)\n"
|
||||
" print('{\"ok\":true,\"event\":\"model_file\",\"state\":\"unknown\",\"file\":\"fake\"}', flush=True)\n"
|
||||
" print('{\"ok\":true,\"event\":\"model_file\",\"file\":{}}', flush=True)\n"
|
||||
" raise SystemExit(1)\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager); manager.install("fake", digest); until_idle(manager);
|
||||
assert(manager.checked() && manager.note().find("Install failed:") == 0);
|
||||
}
|
||||
// A truncated object cannot inject even advisory error text. Complete lines
|
||||
// are bounded too, not only a partial line waiting for its newline.
|
||||
for (bool oversized : {false, true}) {
|
||||
put(service, "import sys\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n " + (oversized ? std::string("print('x' * 9000, flush=True)\n") :
|
||||
std::string("print('{\"ok\":false,\"message\":\"not-real\",', flush=True)\n")) +
|
||||
" raise SystemExit(1)\n");
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager); manager.install("fake", digest); until_idle(manager);
|
||||
assert(manager.note().find("not-real") == std::string::npos);
|
||||
if (oversized) assert(!manager.checked() && manager.note().find("oversized") != std::string::npos);
|
||||
else assert(manager.checked() && manager.note().find("installer exited unsuccessfully") != std::string::npos);
|
||||
}
|
||||
// Cancellation gives the exact owned child a bounded SIGTERM cleanup window.
|
||||
auto started = path / "started", terminated = path / "terminated";
|
||||
put(service, "import pathlib, signal, sys, time\nif '--status' in sys.argv:\n " + status() +
|
||||
"else:\n"
|
||||
" def stop(*_):\n pathlib.Path('" + terminated.string() + "').write_text('done')\n raise SystemExit(1)\n"
|
||||
" signal.signal(signal.SIGTERM, stop)\n"
|
||||
" pathlib.Path('" + started.string() + "').write_text('ready')\n"
|
||||
" time.sleep(60)\n");
|
||||
{
|
||||
BackendManager manager("python3", service.string(), path.string(), path.string(), service.string());
|
||||
manager.refresh(); until_idle(manager); assert(manager.checked());
|
||||
manager.install("fake", digest);
|
||||
for (int n = 0; n < 500 && !std::filesystem::exists(started); ++n)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
assert(std::filesystem::exists(started));
|
||||
manager.cancel();
|
||||
assert(!manager.busy() && std::filesystem::exists(terminated));
|
||||
}
|
||||
std::filesystem::remove_all(path);
|
||||
}
|
||||
+144
-20
@@ -14,7 +14,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0)
|
||||
}
|
||||
void no_action(const SurfaceEvent& event) {
|
||||
assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert &&
|
||||
!event.lock_layout && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings);
|
||||
!event.close_mic_when_idle && !event.lock_layout && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action);
|
||||
}
|
||||
void snapshot(PanelSurface& surface, const std::string& path) {
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
@@ -28,7 +28,7 @@ void snapshot(PanelSurface& surface, const std::string& path) {
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc >= 2);
|
||||
PanelSurface surface(argv[1], Mount::World);
|
||||
Panel p{"Ready to record", "", "Focus your destination before Insert. Enter is always separate.", false, false};
|
||||
Panel p{"Ready to record", "", "Focus your destination before Type. Enter is always separate.", false, false};
|
||||
assert(surface.render(p));
|
||||
assert(surface.pixels().size() == size_t(PanelSurface::width * PanelSurface::height * 4));
|
||||
assert(!surface.render(p));
|
||||
@@ -128,7 +128,7 @@ int main(int argc, char** argv) {
|
||||
p.enabled = true;
|
||||
p.record_available = false;
|
||||
p.status = "Review your words";
|
||||
p.transcript = "A quieter way to type in VR.\nKeep the menu where it feels comfortable.\nNothing is sent until you choose Insert.";
|
||||
p.transcript = "A quieter way to type in VR.\nKeep the menu where it feels comfortable.\nNothing is sent until you choose Type.";
|
||||
assert(surface.render(p));
|
||||
surface.reset_pointers(); surface.render(p);
|
||||
if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-review.ppm");
|
||||
@@ -189,44 +189,56 @@ int main(int argc, char** argv) {
|
||||
assert(click(surface, 680, 332).mount == Mount::RightWrist);
|
||||
assert(click(surface, 680, 260).mount == Mount::Head);
|
||||
assert(click(surface, 180, 260).mount == Mount::World);
|
||||
auto laser = click(surface, 680, 420);
|
||||
auto laser = click(surface, 680, 400);
|
||||
assert(laser.lasers_anytime == true && !laser.action && !laser.mount);
|
||||
surface.set_lasers_anytime(true); assert(surface.render(p));
|
||||
laser = click(surface, 680, 420);
|
||||
laser = click(surface, 680, 400);
|
||||
assert(laser.lasers_anytime == false && !laser.action && !laser.mount);
|
||||
surface.set_lasers_anytime(false); assert(surface.render(p));
|
||||
auto automatic = click(surface, 200, 474);
|
||||
auto automatic = click(surface, 200, 446);
|
||||
assert(automatic.auto_insert == true && !automatic.action);
|
||||
assert(!surface.render(p)); // request alone has no effect
|
||||
surface.set_auto_insert(true); assert(surface.render(p));
|
||||
automatic = click(surface, 200, 474);
|
||||
automatic = click(surface, 200, 446);
|
||||
assert(automatic.auto_insert == false);
|
||||
surface.set_auto_insert(false); assert(surface.render(p));
|
||||
auto debug = click(surface, 680, 474);
|
||||
auto debug = click(surface, 680, 446);
|
||||
assert(debug.advanced_debug == true && !debug.action && !debug.mount && !debug.lasers_anytime);
|
||||
assert(!surface.render(p)); // an event is only a request; caller sets the accepted value
|
||||
surface.set_advanced_debug(true); assert(surface.render(p)); assert(!surface.render(p));
|
||||
surface.pointer_down(1, 680, 474);
|
||||
surface.pointer_down(1, 680, 446);
|
||||
surface.set_advanced_debug(false); assert(surface.render(p));
|
||||
no_action(surface.pointer_up(1, 680, 474)); // stale press cannot toggle after state change
|
||||
debug = click(surface, 680, 474);
|
||||
no_action(surface.pointer_up(1, 680, 446)); // stale press cannot toggle after state change
|
||||
debug = click(surface, 680, 446);
|
||||
assert(debug.advanced_debug == true);
|
||||
surface.set_advanced_debug(true); assert(surface.render(p));
|
||||
debug = click(surface, 680, 474);
|
||||
debug = click(surface, 680, 446);
|
||||
assert(debug.advanced_debug == false);
|
||||
surface.set_advanced_debug(false); assert(surface.render(p));
|
||||
auto clock = click(surface, 200, 528);
|
||||
auto clock = click(surface, 200, 492);
|
||||
assert(clock.clock_24h == true && !clock.action);
|
||||
assert(!surface.render(p)); // setting request waits for caller's accepted value
|
||||
surface.set_clock_24h(true); assert(surface.render(p));
|
||||
assert(click(surface, 200, 528).clock_24h == false);
|
||||
assert(click(surface, 200, 492).clock_24h == false);
|
||||
surface.set_clock_24h(false); assert(surface.render(p));
|
||||
for (DateFormat format : {DateFormat::DayMonthYear, DateFormat::Iso, DateFormat::Off, DateFormat::MonthDayYear}) {
|
||||
auto date = click(surface, 680, 528);
|
||||
auto date = click(surface, 680, 492);
|
||||
assert(date.date_format == format && !date.action);
|
||||
surface.set_date_format(format);
|
||||
assert(surface.render(p));
|
||||
}
|
||||
auto mic = click(surface, 200, 532);
|
||||
assert(mic.close_mic_when_idle == true && !mic.action);
|
||||
assert(!surface.render(p)); // only the caller can accept a setting event
|
||||
surface.set_close_mic_when_idle(true); assert(surface.render(p));
|
||||
surface.pointer_down(1, 200, 532);
|
||||
surface.set_close_mic_when_idle(false); assert(surface.render(p));
|
||||
no_action(surface.pointer_up(1, 200, 532)); // stale press cannot change mic policy
|
||||
mic = click(surface, 200, 532);
|
||||
assert(mic.close_mic_when_idle == true);
|
||||
surface.set_close_mic_when_idle(true); assert(surface.render(p));
|
||||
assert(click(surface, 200, 532).close_mic_when_idle == false);
|
||||
surface.set_close_mic_when_idle(false); assert(surface.render(p));
|
||||
assert(click(surface, 280, 610).action == UiAction::Cancel);
|
||||
surface.set_placement_note("Wrist not tracked - using world space until it returns.");
|
||||
assert(surface.render(p)); assert(!surface.render(p));
|
||||
@@ -237,12 +249,13 @@ int main(int argc, char** argv) {
|
||||
no_action(click(surface, 100, 160));
|
||||
no_action(surface.pointer_up(1, 680, 260));
|
||||
no_action(click(surface, 680, 260)); // mount controls not active on Review
|
||||
no_action(click(surface, 680, 420)); // laser toggle only exists on Settings
|
||||
no_action(click(surface, 680, 474)); // debug toggle only exists on Settings
|
||||
no_action(click(surface, 200, 474)); // auto insert only exists on Settings
|
||||
no_action(click(surface, 680, 400)); // laser toggle only exists on Settings
|
||||
no_action(click(surface, 680, 446)); // debug toggle only exists on Settings
|
||||
no_action(click(surface, 200, 446)); // auto insert only exists on Settings
|
||||
no_action(click(surface, 790, 160)); // layout lock only exists on Settings
|
||||
no_action(click(surface, 200, 528)); // clock/date controls only exist on Settings
|
||||
no_action(click(surface, 680, 528));
|
||||
no_action(click(surface, 200, 492)); // clock/date controls only exist on Settings
|
||||
no_action(click(surface, 680, 492));
|
||||
no_action(click(surface, 200, 532)); // mic preference only exists on Settings
|
||||
|
||||
// Bindings opens SteamVR directly, from either tab, without replacing review.
|
||||
surface.pointer_down(1, 480, 610);
|
||||
@@ -306,5 +319,116 @@ int main(int argc, char** argv) {
|
||||
assert(surface.pointer_up(0, 900, 610, at + PanelSurface::quit_hold).action == UiAction::Quit);
|
||||
assert(surface.render(p));
|
||||
assert(!surface.render(p));
|
||||
// Model page: selection is separate from installation; the first Install
|
||||
// click reveals the source/size/license and cannot launch a child.
|
||||
PanelSurface chooser(argv[1], Mount::World);
|
||||
Panel model_panel{"Ready", "", "", true, false};
|
||||
model_panel.selected_backend = "redux";
|
||||
model_panel.models = {{"redux", "Parakeet Redux", "not_installed", "https://example.org/model",
|
||||
"CC-BY-4.0", "Pinned license text", "Attribution", 177774490, false, std::string(64, 'a')},
|
||||
{"fake", "Fixture", "installed_verified", "https://example.org/fake",
|
||||
"MIT", "Fixture license", "Fixture", 7, true, std::string(64, 'b')}};
|
||||
chooser.render(model_panel);
|
||||
no_action(click(chooser, 290, 160)); // Settings
|
||||
chooser.render(model_panel);
|
||||
no_action(click(chooser, 680, 530)); // Models
|
||||
chooser.render(model_panel);
|
||||
auto select = click(chooser, 100, 310);
|
||||
assert(select.model_action && select.model_action->id == "fake" && !select.model_action->install);
|
||||
model_panel.selected_backend = "fake";
|
||||
chooser.render(model_panel);
|
||||
no_action(click(chooser, 700, 546)); // verified model cannot be installed
|
||||
select = click(chooser, 100, 260);
|
||||
assert(select.model_action && select.model_action->id == "redux" && !select.model_action->install);
|
||||
model_panel.selected_backend = "redux";
|
||||
chooser.render(model_panel);
|
||||
no_action(click(chooser, 700, 546)); // consent preview only
|
||||
chooser.render(model_panel);
|
||||
no_action(click(chooser, 700, 546)); // first page cannot authorize installation
|
||||
while (true) {
|
||||
auto rows = chooser.visible_model_review_lines();
|
||||
assert(!rows.empty());
|
||||
auto before = chooser.pixels();
|
||||
no_action(click(chooser, 370, 546));
|
||||
if (!chooser.render(model_panel)) break; // final page has disabled Next
|
||||
assert(chooser.pixels() != before);
|
||||
}
|
||||
auto install = click(chooser, 700, 546);
|
||||
assert(install.model_action && install.model_action->id == "redux" && install.model_action->install &&
|
||||
install.model_action->manifest_sha256 == std::string(64, 'a'));
|
||||
// Same ID with a new source or fingerprint invalidates displayed approval.
|
||||
no_action(click(chooser, 700, 546));
|
||||
chooser.render(model_panel);
|
||||
model_panel.models[0].source = "https://example.org/changed";
|
||||
model_panel.models[0].manifest_sha256 = std::string(64, 'c');
|
||||
chooser.render(model_panel);
|
||||
no_action(click(chooser, 700, 546)); // now only a new preview, no install
|
||||
chooser.render(model_panel);
|
||||
while (true) {
|
||||
auto before = chooser.pixels();
|
||||
no_action(click(chooser, 370, 546));
|
||||
if (!chooser.render(model_panel)) break;
|
||||
assert(chooser.pixels() != before);
|
||||
}
|
||||
install = click(chooser, 700, 546);
|
||||
assert(install.model_action && install.model_action->manifest_sha256 == std::string(64, 'c'));
|
||||
// Max-length metadata must have every byte represented on navigable pages,
|
||||
// including the source tail, long license text, attribution and exact hash.
|
||||
PanelSurface long_review(argv[1], Mount::World);
|
||||
Panel long_panel{"Ready", "", "", true, false};
|
||||
long_panel.selected_backend = "redux";
|
||||
auto long_model = model_panel.models[0];
|
||||
long_model.source = "https://" + std::string(1016, 's');
|
||||
long_model.license = std::string(1024, 'L');
|
||||
long_model.license_text = std::string(1024, 'T');
|
||||
long_model.attribution = std::string(1024, 'A');
|
||||
long_panel.models = {long_model};
|
||||
long_review.render(long_panel);
|
||||
no_action(click(long_review, 290, 160)); long_review.render(long_panel);
|
||||
no_action(click(long_review, 680, 530)); long_review.render(long_panel);
|
||||
no_action(click(long_review, 700, 546));
|
||||
assert(long_review.visible_model_review_lines().empty()); // preview not yet drawn
|
||||
no_action(click(long_review, 370, 546)); // cannot skip unpainted consent page
|
||||
long_review.render(long_panel);
|
||||
std::string reviewed;
|
||||
int pages = 0;
|
||||
for (;;) {
|
||||
auto rows = long_review.visible_model_review_lines();
|
||||
assert(!rows.empty());
|
||||
for (const auto& row : rows) reviewed += row;
|
||||
++pages;
|
||||
if (pages == 1) no_action(click(long_review, 700, 546));
|
||||
auto before = long_review.pixels();
|
||||
no_action(click(long_review, 370, 546));
|
||||
if (long_review.visible_model_review_lines().empty()) {
|
||||
no_action(click(long_review, 700, 546)); // next page not painted yet
|
||||
no_action(click(long_review, 370, 546)); // cannot skip next unpainted page
|
||||
}
|
||||
if (!long_review.render(long_panel)) break;
|
||||
assert(long_review.pixels() != before);
|
||||
}
|
||||
assert(pages > 8);
|
||||
for (const auto& value : {long_model.source, long_model.license, long_model.license_text,
|
||||
long_model.attribution, long_model.manifest_sha256})
|
||||
assert(reviewed.find(value) != std::string::npos);
|
||||
auto long_install = click(long_review, 700, 546);
|
||||
assert(long_install.model_action && long_install.model_action->install &&
|
||||
long_install.model_action->manifest_sha256 == long_model.manifest_sha256);
|
||||
long_panel.model_note = "Downloading: weights.dat";
|
||||
long_review.render(long_panel);
|
||||
auto without_error = long_review.pixels();
|
||||
long_panel.model_note = "Install failed: manifest_mismatch: pinned metadata changed; local model status checked offline.";
|
||||
assert(long_review.render(long_panel));
|
||||
bool footer_note_changed = false;
|
||||
for (int y = 644; y < 680; ++y) for (int x = 32; x < 968; ++x) {
|
||||
auto pixel = (size_t(y) * PanelSurface::width + x) * 4;
|
||||
footer_note_changed |= long_review.pixels()[pixel] != without_error[pixel];
|
||||
}
|
||||
assert(footer_note_changed); // feedback below buttons, never underneath them
|
||||
chooser.render(model_panel);
|
||||
chooser.pointer_down(0, 700, 546);
|
||||
model_panel.model_busy = true;
|
||||
chooser.render(model_panel);
|
||||
no_action(chooser.pointer_up(0, 700, 546)); // change invalidates approval
|
||||
std::cout << "panel checks passed (no OpenVR, microphone or input injection)\n";
|
||||
}
|
||||
@@ -142,6 +142,14 @@ class BackendDispatchTest(unittest.TestCase):
|
||||
self.assertNotEqual(wrong.returncode, 0)
|
||||
self.assertFalse(marker.exists(), wrong.stdout)
|
||||
self.assertNotIn('DONE', wrong.stdout)
|
||||
self.assertEqual(json.loads(wrong.stdout), {
|
||||
'ok': False, 'code': 'manifest_mismatch',
|
||||
'message': 'selected backend manifest changed since consent'})
|
||||
missing = subprocess.run(install[:-1] + [hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
'--installer', str(root / 'absent.sh')], capture_output=True, text=True)
|
||||
self.assertNotEqual(missing.returncode, 0)
|
||||
self.assertEqual(json.loads(missing.stdout)['message'], 'installer missing or unsafe')
|
||||
self.assertFalse(marker.exists())
|
||||
# Reusing a backend ID cannot silently re-authorize new sources.
|
||||
new = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
self.assertNotEqual(new, old)
|
||||
|
||||
Reference in new issue
Block a user