feat(ui): add consent-bound model chooser and microphone controls

This commit is contained in:
baketnk committed 2026-09-24 23:03:09 -04:00
1 parent 665d003782
commit 4498ef5d04
12 files changed
+1025 -168

No files matched your search

+153
View File
@@ -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
View File
@@ -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";
}
+8
View File
@@ -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)