Add XDG JSON theme, font and button configuration with installer repair

This commit is contained in:
baketnk committed 2026-09-24 13:54:58 -04:00
1 parent 1684040abe
commit 1496e436eb
16 files changed
+717 -46

No files matched your search

+6 -1
View File
@@ -31,11 +31,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(FRAMEYAP_NATIVE "Build opt-in OpenVR/SDL3/Gamescope runtime (no automatic downloads)" OFF)
option(FRAMEYAP_UI_TESTS "Build hardware-free panel renderer tests (requires FreeType)" OFF)
add_library(frameyap_config src/config.cpp)
target_include_directories(frameyap_config PUBLIC src)
if(FRAMEYAP_NATIVE OR FRAMEYAP_UI_TESTS)
find_package(Freetype REQUIRED)
add_library(frameyap_panel src/panel_surface.cpp)
target_include_directories(frameyap_panel PUBLIC src)
target_link_libraries(frameyap_panel PRIVATE Freetype::Freetype)
target_link_libraries(frameyap_panel PUBLIC frameyap_config PRIVATE Freetype::Freetype)
endif()
add_library(frameyap_core src/core.cpp)
@@ -108,6 +110,9 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
add_test(NAME frameyap.panel COMMAND frameyap_panel_test "${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/Inconsolata-Regular.ttf")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_executable(frameyap_config_test tests/config_test.cpp)
target_link_libraries(frameyap_config_test PRIVATE frameyap_config)
add_test(NAME frameyap.config COMMAND frameyap_config_test "${CMAKE_CURRENT_SOURCE_DIR}/assets")
add_executable(frameyap_mount_test tests/mount_test.cpp)
target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount)
add_test(NAME frameyap.mount COMMAND frameyap_mount_test)
+4
View File
@@ -27,6 +27,10 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
Review and settings share one Inconsolata/neon-framed surface. World-space by
default; settings offer left wrist, right wrist and head mounting, plus recenter.
Dashboard lasers provide clickable controls without forcing global laser mode.
- **Theme and controls:** optional `$XDG_CONFIG_HOME/frameyap/config.json` selects
panel colors, a font path and Frame controller button mappings; missing fonts
fall back to bundled Inconsolata. The installer creates/checks this file and
backs it up before repairs. See [overlay configuration](docs/overlay.md#user-theme-and-controller-configuration).
- **Review-first:** focus your destination, then press Insert. No automatic insertion
or submission. Maximum clip 20 seconds; accidental taps under 200 ms are discarded.
- Other apps may still hear/transmit your voice. FrameYap does not mute them.
+21
View File
@@ -0,0 +1,21 @@
{
"font": "",
"theme": {
"background": "#0c101b",
"card": "#141c2b",
"ink": "#e6f0f9",
"muted": "#97adc1",
"accent": "#1ff0a4",
"warning": "#ff6e87",
"frame_start": "#1fff91",
"frame_end": "#1f70ff"
},
"buttons": {
"left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip",
"ptt": "/user/hand/right/input/x",
"cancel": "",
"insert": "",
"enter": ""
}
}
+46 -3
View File
@@ -12,9 +12,10 @@ normal build or test.
Explicit development dependencies: Valve OpenVR SDK v2.15.6 and FreeType 2.
Configure/build must not fetch them. The default font is the bundled Inconsolata
Regular, also used by kouseki; its OFL and extraction provenance are included in
[third-party notes](third-party.md). `--font FILE` is an explicit override. Glyph
coverage depends on the selected face (no automatic system-font fallback or full
CJK coverage is claimed).
[third-party notes](third-party.md). `--font FILE` overrides the JSON selection.
A missing selected font falls back to bundled Inconsolata, then a system DejaVu
Sans face if present. Glyph coverage depends on the selected face; full CJK
coverage is not claimed.
`src/panel_surface.*` renders **one 1000×680 RGBA canvas** for review, settings,
status and controls. One OpenVR handle receives it with `SetOverlayRaw`; tabs do
@@ -42,6 +43,48 @@ a press/release on the same enabled control from the same cursor; focus loss,
tab changes, action-state changes and relocation clear pending presses. Enter is *always* a separate
deliberate action, not inferred from text. Recording never automatically submits Enter.
### User theme and controller configuration
Optional `$XDG_CONFIG_HOME/frameyap/config.json` (fallback
`~/.config/frameyap/config.json`, only with an absolute HOME) is read at native
overlay startup. The native binary does not create a config by itself; the
installer creates one with defaults on first install. Copy the shipped
`assets/config.example.json` to that path for manual installs. Example:
```json
{
"font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"theme": {
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"
},
"buttons": {
"ptt": "/user/hand/right/input/x",
"left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip"
}
}
```
Each theme color is `#RRGGBB`; omitted colors keep the default. `font` is a
TTF/OTF file path (not a family name); a missing file uses the bundled font.
`buttons` maps named OpenVR actions (`left_grip`, `right_grip`, `ptt`, `cancel`,
`insert`, `enter`) to Frame physical `/user/hand/{left|right}/input/NAME`
button paths. Omitted actions retain their bundled defaults; an empty string
disables a mapping. Paths must be distinct. Only the Frame binding is customized;
SteamVR user overrides may still supersede it. On customized launches a generated
action manifest and adjacent bindings are placed in `$XDG_CACHE_HOME/frameyap/bindings`
(or `~/.cache/frameyap/bindings`); the bundled manifest remains unchanged. The
config is read once at launch, not hot-reloaded. On install/upgrade the installer
fills missing fields, removes retired keys, and resets invalid entries. It saves
the exact prior bytes under `config.json.backup-*` before a repair and refuses
symlink/oversized config paths; valid customizations remain intact. The installed
launcher no longer pins `--font`, so this selection takes effect. Direct native
launches with bad JSON, colors or button mappings fail startup rather than
silently changing input behavior. This does not enable experimental SteamVR
action overrides or prove delivery in games.
### Placement settings
First launch defaults to **World space**: a standing-universe absolute transform,
+8 -2
View File
@@ -89,12 +89,13 @@ model=/absolute/path/to/pinned/local/model
The launcher reads these as **literal absolute paths**, not shell code, and does
not follow a symlink to the config file. Environment variables override either
setting for an explicit launch. This user-owned config survives upgrade/uninstall;
setting for an explicit launch. This `paths.conf` survives upgrade/uninstall;
the installer does not populate it or bundle an unauthorized runtime. No
pip/bootstrap/model download or fallback is invoked by the launcher. Without
paths, the native-only artifact cannot perform voice inference; its default
runtime and model locations do not exist. A new installer accepts only the exact
previous managed launcher bytes for migration; a modified launcher is refused.
The current managed launcher leaves `--font` unset so `config.json` can choose it.
Without any ASR runtime, these checks work directly through the installed launcher:
@@ -114,7 +115,12 @@ launcher: `~/.local/bin/frameyap`; desktop entry:
`$XDG_DATA_HOME/applications/frameyap.desktop` (default
`~/.local/share/applications/frameyap.desktop`). The desktop entry points to the
user-local launcher; the installer never edits Steam's library or registers a
Steam shortcut. Existing config is untouched. The launcher
Steam shortcut. Install/upgrade creates or checks `$XDG_CONFIG_HOME/frameyap/config.json`
(default `~/.config/frameyap/config.json`), filling missing properties or repairing
invalid JSON/values while preserving valid customization. Before each repair it
saves an exact-byte `config.json.backup-*` next to the original; valid config
is left untouched. Symlinks and oversized config files are refused, and uninstall
leaves both the config and backups in place. `paths.conf` is not modified. The launcher
passes a stable install-root lock identity and sets `PYTHONDONTWRITEBYTECODE=1`.
Runtime/check/registration modes and installer share an exclusive nonblocking
`.lock`; no upgrade/rollback/uninstall kills a running app or any other process.
+95 -8
View File
@@ -31,6 +31,17 @@ ARCHIVE_LIMIT = 12 * 1024**3
MEMBER_LIMIT = 50000
VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z")
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = {
"font": "",
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
"buttons": {"left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip",
"ptt": "/user/hand/right/input/x", "cancel": "", "insert": "", "enter": ""},
}
COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z")
BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z")
def fail(message):
@@ -55,6 +66,78 @@ def atomic_write(path, content, mode=0o600):
os.unlink(name)
def config_path():
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path.home() / ".config"
return base / "frameyap/config.json"
def unique_pairs(pairs):
result = {}
for key, value in pairs:
if key in result:
fail(f"duplicate JSON config key: {key}")
result[key] = value
return result
def normalized_config(data):
# Invalid individual values reset to defaults. Unknown/retired properties
# are removed; the exact original bytes are backed up before any rewrite.
if not isinstance(data, dict):
data = {}
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
for section in ("theme", "buttons"):
source = data.get(section)
source = source if isinstance(source, dict) else {}
fixed[section] = {}
for name, default in CONFIG_DEFAULTS[section].items():
value = source.get(name, default)
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
else (not value or BUTTON_RE.fullmatch(value))))
fixed[section][name] = value if valid else default
paths = [value for value in fixed["buttons"].values() if value]
if len(paths) != len(set(paths)):
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
size = lambda: len((json.dumps(fixed, indent=2, sort_keys=True) + "\n").encode())
if size() > 4096:
fixed["font"] = ""
if size() > 4096:
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
return fixed
def repair_user_config():
path = config_path()
owned_dir(path.parent.parent)
owned_dir(path.parent)
if path.is_symlink() or (path.exists() and not path.is_file()):
fail(f"refusing foreign config path: {path}")
original = None
parsed = None
if path.exists():
if path.stat().st_size > 65536:
fail(f"config too large to safely back up: {path}")
original = path.read_bytes()
try:
if len(original) > 4096: # native config reader has the same bound
fail("config exceeds native 4096-byte limit")
parsed = json.loads(original.decode("utf-8"), object_pairs_hook=unique_pairs)
except (ValueError, UnicodeError):
pass
fixed = normalized_config(parsed)
if original is not None and parsed == fixed:
return # Keep valid user formatting, permissions and custom values.
if original is not None:
fd, backup = tempfile.mkstemp(prefix="config.json.backup-", dir=path.parent)
with os.fdopen(fd, "wb") as stream:
stream.write(original)
stream.flush()
os.fsync(stream.fileno())
print(f"Preserved previous FrameYap config at {backup}")
json_atomic(path, fixed)
def owned_dir(path):
if path.is_symlink():
fail(f"refusing symlink directory: {path}")
@@ -200,13 +283,13 @@ def select(root, link, target):
temp.unlink(missing_ok=True)
def desired_launcher(root, legacy=False):
def desired_launcher(root, legacy=False, pinned_font=False):
import shlex
q = lambda path: shlex.quote(str(path))
font = root / "current/fonts/font.ttf"
base = root / "current"
flags = ["--assets", base / "assets", "--font", font,
"--worker", base / "python/frameyap/worker.py"]
flags = ["--assets", base / "assets", "--worker", base / "python/frameyap/worker.py"]
if pinned_font or legacy:
flags[2:2] = ["--font", base / "fonts/font.ttf"] # exact previously managed launchers
args = " ".join(q(item) for item in flags)
# Parse two literal absolute paths, never source/eval this user-owned file.
# Environment overrides remain useful for a one-off explicit launch.
@@ -225,6 +308,7 @@ def desired_launcher(root, legacy=False):
'${CONFIG_PYTHON:-' + q(base / "runtime/bin/python3") + '}')
model_default = (q(base / "model") if legacy else
'${CONFIG_MODEL:-' + q(base / "model") + '}')
check_font = ' --font ' + q(base / "fonts/font.ttf") if legacy or pinned_font else ''
return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\n'
+ f'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
@@ -236,7 +320,7 @@ def desired_launcher(root, legacy=False):
+ ' --run)\n'
+ ' if [ -n "${GAMESCOPE_SOCKET:-}" ]; then set -- "$@" --socket "$GAMESCOPE_SOCKET"; fi\n'
+ f" shift; exec {q(base / 'bin/frameyap')} --run {args} --python \"$PYTHON\" --model \"$MODEL\" \"$@\";;\n"
+ f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')} --font {q(font)} \"$@\";;\n"
+ f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')}{check_font} \"$@\";;\n"
+ f" *) exec {q(base / 'bin/frameyap')} \"$@\";;\n"
+ 'esac\n').encode()
@@ -274,8 +358,9 @@ def check_owned_file(path, expected, alternatives=()):
def check_wrappers(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
# Accept only the exact earlier managed script for migration.
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
# Accept only exact earlier managed scripts for migration.
check_owned_file(launcher, desired_launcher(root),
(desired_launcher(root, pinned_font=True), desired_launcher(root, legacy=True)))
check_owned_file(manifest, desired)
check_owned_file(desktop_path(root), desired_desktop(launcher))
return manifest, desired
@@ -388,6 +473,7 @@ def do_install(args, root, launcher):
finally:
if temp.exists():
shutil.rmtree(temp)
repair_user_config()
install_files(root, target, launcher)
if current == f"versions/{version}":
print(f"FrameYap {version}: already installed (same digest); wrappers verified")
@@ -406,7 +492,8 @@ def uninstall(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(manifest, desired)
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
check_owned_file(launcher, desired_launcher(root),
(desired_launcher(root, pinned_font=True), desired_launcher(root, legacy=True)))
desktop = desktop_path(root)
check_owned_file(desktop, desired_desktop(launcher))
versions = root / "versions"
+95 -8
View File
@@ -20,6 +20,17 @@ ARCHIVE_LIMIT = 12 * 1024**3
MEMBER_LIMIT = 50000
VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z")
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = {
"font": "",
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
"buttons": {"left_grip": "/user/hand/left/input/grip",
"right_grip": "/user/hand/right/input/grip",
"ptt": "/user/hand/right/input/x", "cancel": "", "insert": "", "enter": ""},
}
COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z")
BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z")
def fail(message):
@@ -44,6 +55,78 @@ def atomic_write(path, content, mode=0o600):
os.unlink(name)
def config_path():
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path.home() / ".config"
return base / "frameyap/config.json"
def unique_pairs(pairs):
result = {}
for key, value in pairs:
if key in result:
fail(f"duplicate JSON config key: {key}")
result[key] = value
return result
def normalized_config(data):
# Invalid individual values reset to defaults. Unknown/retired properties
# are removed; the exact original bytes are backed up before any rewrite.
if not isinstance(data, dict):
data = {}
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
for section in ("theme", "buttons"):
source = data.get(section)
source = source if isinstance(source, dict) else {}
fixed[section] = {}
for name, default in CONFIG_DEFAULTS[section].items():
value = source.get(name, default)
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
else (not value or BUTTON_RE.fullmatch(value))))
fixed[section][name] = value if valid else default
paths = [value for value in fixed["buttons"].values() if value]
if len(paths) != len(set(paths)):
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
size = lambda: len((json.dumps(fixed, indent=2, sort_keys=True) + "\n").encode())
if size() > 4096:
fixed["font"] = ""
if size() > 4096:
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
return fixed
def repair_user_config():
path = config_path()
owned_dir(path.parent.parent)
owned_dir(path.parent)
if path.is_symlink() or (path.exists() and not path.is_file()):
fail(f"refusing foreign config path: {path}")
original = None
parsed = None
if path.exists():
if path.stat().st_size > 65536:
fail(f"config too large to safely back up: {path}")
original = path.read_bytes()
try:
if len(original) > 4096: # native config reader has the same bound
fail("config exceeds native 4096-byte limit")
parsed = json.loads(original.decode("utf-8"), object_pairs_hook=unique_pairs)
except (ValueError, UnicodeError):
pass
fixed = normalized_config(parsed)
if original is not None and parsed == fixed:
return # Keep valid user formatting, permissions and custom values.
if original is not None:
fd, backup = tempfile.mkstemp(prefix="config.json.backup-", dir=path.parent)
with os.fdopen(fd, "wb") as stream:
stream.write(original)
stream.flush()
os.fsync(stream.fileno())
print(f"Preserved previous FrameYap config at {backup}")
json_atomic(path, fixed)
def owned_dir(path):
if path.is_symlink():
fail(f"refusing symlink directory: {path}")
@@ -189,13 +272,13 @@ def select(root, link, target):
temp.unlink(missing_ok=True)
def desired_launcher(root, legacy=False):
def desired_launcher(root, legacy=False, pinned_font=False):
import shlex
q = lambda path: shlex.quote(str(path))
font = root / "current/fonts/font.ttf"
base = root / "current"
flags = ["--assets", base / "assets", "--font", font,
"--worker", base / "python/frameyap/worker.py"]
flags = ["--assets", base / "assets", "--worker", base / "python/frameyap/worker.py"]
if pinned_font or legacy:
flags[2:2] = ["--font", base / "fonts/font.ttf"] # exact previously managed launchers
args = " ".join(q(item) for item in flags)
# Parse two literal absolute paths, never source/eval this user-owned file.
# Environment overrides remain useful for a one-off explicit launch.
@@ -214,6 +297,7 @@ def desired_launcher(root, legacy=False):
'${CONFIG_PYTHON:-' + q(base / "runtime/bin/python3") + '}')
model_default = (q(base / "model") if legacy else
'${CONFIG_MODEL:-' + q(base / "model") + '}')
check_font = ' --font ' + q(base / "fonts/font.ttf") if legacy or pinned_font else ''
return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\n'
+ f'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
@@ -225,7 +309,7 @@ def desired_launcher(root, legacy=False):
+ ' --run)\n'
+ ' if [ -n "${GAMESCOPE_SOCKET:-}" ]; then set -- "$@" --socket "$GAMESCOPE_SOCKET"; fi\n'
+ f" shift; exec {q(base / 'bin/frameyap')} --run {args} --python \"$PYTHON\" --model \"$MODEL\" \"$@\";;\n"
+ f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')} --font {q(font)} \"$@\";;\n"
+ f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')}{check_font} \"$@\";;\n"
+ f" *) exec {q(base / 'bin/frameyap')} \"$@\";;\n"
+ 'esac\n').encode()
@@ -263,8 +347,9 @@ def check_owned_file(path, expected, alternatives=()):
def check_wrappers(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
# Accept only the exact earlier managed script for migration.
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
# Accept only exact earlier managed scripts for migration.
check_owned_file(launcher, desired_launcher(root),
(desired_launcher(root, pinned_font=True), desired_launcher(root, legacy=True)))
check_owned_file(manifest, desired)
check_owned_file(desktop_path(root), desired_desktop(launcher))
return manifest, desired
@@ -377,6 +462,7 @@ def do_install(args, root, launcher):
finally:
if temp.exists():
shutil.rmtree(temp)
repair_user_config()
install_files(root, target, launcher)
if current == f"versions/{version}":
print(f"FrameYap {version}: already installed (same digest); wrappers verified")
@@ -395,7 +481,8 @@ def uninstall(root, launcher):
manifest = root / "frameyap.vrmanifest"
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
check_owned_file(manifest, desired)
check_owned_file(launcher, desired_launcher(root), (desired_launcher(root, legacy=True),))
check_owned_file(launcher, desired_launcher(root),
(desired_launcher(root, pinned_font=True), desired_launcher(root, legacy=True)))
desktop = desktop_path(root)
check_owned_file(desktop, desired_desktop(launcher))
versions = root / "versions"
+249
View File
@@ -0,0 +1,249 @@
#include "config.hpp"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <stdexcept>
#include <string_view>
namespace frameyap {
namespace {
struct Json {
std::string value;
std::map<std::string, Json> object;
bool is_string = false, is_object = false;
};
struct Parser {
std::string_view s;
size_t pos = 0;
void ws() { while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\n' || s[pos] == '\r' || s[pos] == '\t')) ++pos; }
bool eat(char c) { ws(); if (pos < s.size() && s[pos] == c) { ++pos; return true; } return false; }
[[noreturn]] void fail() const { throw std::runtime_error("Invalid FrameYap JSON config at byte " + std::to_string(pos)); }
std::string str() {
if (!eat('"')) fail();
std::string out;
while (pos < s.size()) {
unsigned char c = s[pos++];
if (c == '"') return out;
if (c < 0x20) fail();
if (c != '\\') { out += char(c); continue; }
if (pos == s.size()) fail();
c = s[pos++];
switch (c) {
case '"': case '\\': case '/': out += char(c); break;
case 'b': out += '\b'; break; case 'f': out += '\f'; break;
case 'n': out += '\n'; break; case 'r': out += '\r'; break; case 't': out += '\t'; break;
case 'u': {
auto hex = [&]() {
unsigned n = 0;
for (int i = 0; i < 4; ++i) {
if (pos == s.size()) fail();
char h = s[pos++];
if (!std::isxdigit(static_cast<unsigned char>(h))) fail();
n = n * 16 + (h <= '9' ? h - '0' : (h <= 'F' ? h - 'A' + 10 : h - 'a' + 10));
}
return n;
};
unsigned cp = hex();
if (cp >= 0xd800 && cp <= 0xdbff) {
if (pos + 2 > s.size() || s.substr(pos, 2) != "\\u") fail();
pos += 2;
unsigned low = hex();
if (low < 0xdc00 || low > 0xdfff) fail();
cp = 0x10000 + ((cp - 0xd800) << 10) + low - 0xdc00;
} else if (cp >= 0xdc00 && cp <= 0xdfff) fail();
if (cp < 0x80) out += char(cp);
else if (cp < 0x800) { out += char(0xc0 | (cp >> 6)); out += char(0x80 | (cp & 63)); }
else if (cp < 0x10000) { out += char(0xe0 | (cp >> 12)); out += char(0x80 | ((cp >> 6) & 63)); out += char(0x80 | (cp & 63)); }
else { out += char(0xf0 | (cp >> 18)); out += char(0x80 | ((cp >> 12) & 63)); out += char(0x80 | ((cp >> 6) & 63)); out += char(0x80 | (cp & 63)); }
break;
}
default: fail();
}
}
fail();
}
Json parse(unsigned depth = 0) {
if (depth > 8) fail();
ws();
if (pos == s.size()) fail();
Json result;
if (s[pos] == '"') { result.value = str(); result.is_string = true; return result; }
if (eat('{')) {
result.is_object = true;
if (eat('}')) return result;
do {
ws(); if (pos == s.size() || s[pos] != '"') fail();
auto key = str();
if (!eat(':')) fail();
auto [it, inserted] = result.object.emplace(std::move(key), parse(depth + 1));
if (!inserted) fail();
if (eat('}')) return result;
} while (eat(','));
fail();
}
if (eat('[')) {
if (eat(']')) return result;
do { parse(depth + 1); if (eat(']')) return result; } while (eat(','));
fail();
}
size_t start = pos;
if (s.substr(pos, 4) == "true" || s.substr(pos, 4) == "null") pos += 4;
else if (s.substr(pos, 5) == "false") pos += 5;
else {
if (s[pos] == '-') ++pos;
if (pos == s.size()) fail();
if (s[pos] == '0') ++pos;
else { if (s[pos] < '1' || s[pos] > '9') fail(); while (pos < s.size() && std::isdigit(static_cast<unsigned char>(s[pos]))) ++pos; }
if (pos < s.size() && s[pos] == '.') { ++pos; size_t at = pos; while (pos < s.size() && std::isdigit(static_cast<unsigned char>(s[pos]))) ++pos; if (at == pos) fail(); }
if (pos < s.size() && (s[pos] == 'e' || s[pos] == 'E')) {
++pos; if (pos < s.size() && (s[pos] == '-' || s[pos] == '+')) ++pos;
size_t at = pos; while (pos < s.size() && std::isdigit(static_cast<unsigned char>(s[pos]))) ++pos;
if (at == pos) fail();
}
}
if (pos == start) fail();
return result;
}
};
Rgba color(const Json& json) {
if (!json.is_string || json.value.size() != 7 || json.value[0] != '#')
throw std::runtime_error("Theme colors must be #RRGGBB strings");
Rgba c{0, 0, 0, 255};
for (int i = 0; i < 3; ++i) {
auto digit = [](char ch) -> int {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return -1;
};
int hi = digit(json.value[1 + 2*i]), lo = digit(json.value[2 + 2*i]);
if (hi < 0 || lo < 0) throw std::runtime_error("Theme colors must be #RRGGBB strings");
c[i] = static_cast<unsigned char>(hi * 16 + lo);
}
return c;
}
std::filesystem::path xdg_root(const char* variable, const char* fallback) {
const char* env = std::getenv(variable);
if (env && *env && std::filesystem::path(env).is_absolute()) return env;
const char* home = std::getenv("HOME");
if (home && *home && std::filesystem::path(home).is_absolute()) return std::filesystem::path(home) / fallback;
return {};
}
std::string read_file(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) throw std::runtime_error("Cannot read " + p.string());
std::string data;
char buf[4097];
in.read(buf, sizeof(buf));
if (in.gcount() == sizeof(buf)) throw std::runtime_error("FrameYap config/input file too large: " + p.string());
data.assign(buf, size_t(in.gcount()));
return data;
}
void write_file(const std::filesystem::path& path, const std::string& content) {
auto temporary = path.string() + ".tmp." + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
try {
{
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
out << content;
if (!out) throw std::runtime_error("Could not write generated OpenVR binding: " + path.string());
}
std::filesystem::rename(temporary, path);
} catch (...) {
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
throw;
}
}
} // namespace
std::filesystem::path default_config_path() {
auto root = xdg_root("XDG_CONFIG_HOME", ".config");
return root.empty() ? root : root / "frameyap/config.json";
}
Config load_config(const std::filesystem::path& path) {
Config config;
if (path.empty() || !std::filesystem::exists(path)) return config;
auto bytes = read_file(path);
Parser parser{bytes};
auto root = parser.parse(); parser.ws();
if (parser.pos != bytes.size() || !root.is_object) throw std::runtime_error("Invalid FrameYap config object: " + path.string());
for (const auto& [key, value] : root.object) {
if (key == "font") {
if (!value.is_string) throw std::runtime_error("Config font must be a path string");
config.font = value.value;
} else if (key == "theme") {
if (!value.is_object) throw std::runtime_error("Config theme must be an object");
for (const auto& [name, v] : value.object) {
Rgba* target = nullptr;
if (name == "background") target = &config.theme.background;
else if (name == "card") target = &config.theme.card;
else if (name == "ink") target = &config.theme.ink;
else if (name == "muted") target = &config.theme.muted;
else if (name == "accent") target = &config.theme.accent;
else if (name == "warning") target = &config.theme.warning;
else if (name == "frame_start") target = &config.theme.frame_start;
else if (name == "frame_end") target = &config.theme.frame_end;
else throw std::runtime_error("Unknown theme color: " + name);
*target = color(v);
}
} else if (key == "buttons") {
if (!value.is_object) throw std::runtime_error("Config buttons must be an object");
for (const auto& [name, v] : value.object) {
if (name != "left_grip" && name != "right_grip" && name != "ptt" && name != "cancel" && name != "insert" && name != "enter")
throw std::runtime_error("Unknown OpenVR button action: " + name);
if (!v.is_string) throw std::runtime_error("Button path must be a string");
auto path = v.value;
if (!path.empty()) {
auto valid = [](std::string_view prefix, const std::string& path) {
return path.starts_with(prefix) && path.size() > prefix.size() &&
std::all_of(path.begin() + prefix.size(), path.end(), [](unsigned char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_';
});
};
if (!valid("/user/hand/left/input/", path) && !valid("/user/hand/right/input/", path))
throw std::runtime_error("Invalid Frame controller button path: " + path);
}
config.buttons[name] = path;
}
} else throw std::runtime_error("Unknown FrameYap config key: " + key);
}
return config;
}
std::string resolve_font(const std::string& assets, const std::string& requested) {
const auto bundled = std::filesystem::path(assets) / "fonts/Inconsolata-Regular.ttf";
const std::array<std::filesystem::path, 4> candidates{requested, bundled,
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf"};
for (const auto& candidate : candidates)
if (!candidate.empty() && std::filesystem::is_regular_file(candidate)) return candidate.string();
throw std::runtime_error("No panel font found (set font in config.json or install bundled font)");
}
std::filesystem::path action_manifest(const std::string& assets, const Config& config) {
auto source = std::filesystem::absolute(std::filesystem::path(assets) / "actions.json");
if (config.buttons.empty()) return source;
auto root = xdg_root("XDG_CACHE_HOME", ".cache");
if (root.empty()) throw std::runtime_error("No absolute XDG_CACHE_HOME or HOME for generated bindings");
auto dir = root / "frameyap/bindings";
std::filesystem::create_directories(dir);
std::map<std::string, std::string> buttons{{"left_grip", "/user/hand/left/input/grip"},
{"right_grip", "/user/hand/right/input/grip"}, {"ptt", "/user/hand/right/input/x"}};
for (const auto& [key, path] : config.buttons) buttons[key] = path;
std::string binding = "{\"controller_type\":\"frame_controller\",\"name\":\"FrameYap configured controls\",\"bindings\":{\"/actions/frameyap\":{\"sources\":[";
std::map<std::string, std::string> used;
for (const auto& [action, path] : buttons) {
if (path.empty()) continue;
if (!used.emplace(path, action).second) throw std::runtime_error("Two actions share Frame button: " + path);
if (binding.back() != '[') binding += ',';
binding += "{\"path\":\"" + path + "\",\"mode\":\"button\",\"inputs\":{\"click\":{\"output\":\"/actions/frameyap/in/" + action + "\"}}}";
}
binding += "]}}}";
auto manifest = read_file(source);
// The OpenVR SDK resolves relative binding URLs beside its manifest.
write_file(dir / "bindings_frame_controller.json", binding);
write_file(dir / "bindings_knuckles.json", read_file(std::filesystem::path(assets) / "bindings_knuckles.json"));
write_file(dir / "actions.json", manifest);
return dir / "actions.json";
}
} // namespace frameyap
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <array>
#include <filesystem>
#include <map>
#include <string>
namespace frameyap {
using Rgba = std::array<unsigned char, 4>;
struct Theme {
Rgba background{12, 16, 27, 255}, card{20, 28, 43, 255};
Rgba ink{230, 240, 249, 255}, muted{151, 173, 193, 255};
Rgba accent{31, 240, 164, 255}, warning{255, 110, 135, 255};
Rgba frame_start{31, 255, 145, 255}, frame_end{31, 112, 255, 255};
};
struct Config {
Theme theme;
std::string font; // absolute TTF/OTF path; empty uses the bundled face
// OpenVR action name -> physical Frame controller input path; empty disables it.
std::map<std::string, std::string> buttons;
};
std::filesystem::path default_config_path();
Config load_config(const std::filesystem::path& path);
std::string resolve_font(const std::string& assets, const std::string& requested);
// No writes or OpenVR access when no custom button mappings are specified.
// When customized, build a generated manifest and bindings under XDG cache.
std::filesystem::path action_manifest(const std::string& assets, const Config& config);
} // namespace frameyap
+3 -1
View File
@@ -23,7 +23,9 @@ void help() {
" --check-controls --assets DIR [--font FILE] [--mount MODE] (30s, gestures only)\n\n"
"Mount: world (first-launch default), left-wrist, right-wrist, head.\n"
"Settings save the mount; --mount overrides it for this launch. --head is an alias.\n"
"Font defaults to bundled Inconsolata in the assets directory.\n"
"Theme, font and Frame button mappings: $XDG_CONFIG_HOME/frameyap/config.json\n"
"(or ~/.config/frameyap/config.json). CLI --font overrides config; missing fonts\n"
"fall back to bundled Inconsolata, then a system DejaVu face.\n"
"Right X: hold to speak, release to review (default Frame binding).\n"
"Grip gestures are remappable but may be unavailable in the dashboard.\n"
"Left grip: double-tap for explicit Enter when active.\n"
+4 -2
View File
@@ -51,6 +51,7 @@ struct Overlay::Impl {
std::array<vr::VRActionHandle_t, 6> actions{};
std::filesystem::path settings_path;
Mount mount;
Config config;
PanelSurface surface;
Panel panel;
bool save_failed = false;
@@ -75,9 +76,10 @@ struct Overlay::Impl {
Impl(const std::string& assets, const std::string& font, std::optional<Mount> requested, bool persist)
: settings_path(default_mount_settings_path()),
mount(requested ? *requested : load_mount(settings_path)),
surface(font.empty() ? (std::filesystem::path(assets) / "fonts/Inconsolata-Regular.ttf").string() : font, mount),
config(load_config(default_config_path())),
surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme),
persist_mount(persist) {
const auto action_path = absolute_file(std::filesystem::path(assets) / "actions.json");
const auto action_path = absolute_file(action_manifest(assets, config));
absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json");
try {
configure_registry();
+32 -18
View File
@@ -11,10 +11,7 @@
namespace frameyap {
namespace {
constexpr int W = PanelSurface::width, H = PanelSurface::height;
using Color = std::array<unsigned char, 4>;
constexpr Color background{12, 16, 27, 255}, card{20, 28, 43, 255};
constexpr Color ink{230, 240, 249, 255}, muted{151, 173, 193, 255};
constexpr Color cyan{31, 240, 164, 255}, pink{255, 110, 135, 255};
using Color = Rgba;
float rounded_distance(float px, float py, int x, int y, int w, int h, float radius) {
const float dx = std::abs(px - (x + w / 2.f)) - (w / 2.f - radius);
const float dy = std::abs(py - (y + h / 2.f)) - (h / 2.f - radius);
@@ -101,6 +98,8 @@ struct PanelSurface::Impl {
std::vector<unsigned char> pixels = std::vector<unsigned char>(W * H * 4);
Panel panel;
Mount mount;
Theme theme;
Color background, card, ink, muted, cyan, pink;
bool settings = false, dirty = true;
std::string placement_note;
std::array<int, 2> pressed{{-1, -1}};
@@ -108,7 +107,9 @@ struct PanelSurface::Impl {
size_t page = 0;
static constexpr size_t lines_per_page = 4;
Impl(const std::string& font, Mount m) : mount(m) {
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),
cyan(t.accent), pink(t.warning) {
if (FT_Init_FreeType(&library)) throw std::runtime_error("FreeType initialization failed");
if (FT_New_Face(library, font.c_str(), 0, &face)) {
FT_Done_FreeType(library);
@@ -119,6 +120,13 @@ struct PanelSurface::Impl {
void size(unsigned px) {
if (FT_Set_Pixel_Sizes(face, 0, px)) throw std::runtime_error("Could not size panel font");
}
Color mix(Color a, Color b, float t) const {
Color result{};
for (int k = 0; k < 3; ++k)
result[k] = static_cast<unsigned char>(a[k] * (1.f - t) + b[k] * t);
result[3] = 255;
return result;
}
void rect(Rect r, Color c) {
for (int y = std::max(r.y, 0); y < std::min(H, r.y + r.h); ++y)
for (int x = std::max(r.x, 0); x < std::min(W, r.x + r.w); ++x)
@@ -154,8 +162,10 @@ struct PanelSurface::Impl {
const float edge = std::abs(distance);
const float strength = edge <= 1.f ? 1.f : .30f * std::max(0.f, 1.f - (edge - 1.f) / 6.f);
const float t = float(x) / W;
const Color gradient{31, static_cast<unsigned char>(255 - 143 * t),
static_cast<unsigned char>(145 + 110 * t), 255};
Color gradient{};
for (int k = 0; k < 3; ++k)
gradient[k] = static_cast<unsigned char>(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t);
gradient[3] = 255;
auto* dst = pixels.data() + (size_t(y) * W + x) * 4;
for (int k = 0; k < 3; ++k) dst[k] = static_cast<unsigned char>(background[k] + (gradient[k] - background[k]) * strength);
dst[3] = distance <= 1.f ? 255 : static_cast<unsigned char>(255 * std::max(0.f, 1.f - (distance - 1.f) / 6.f));
@@ -163,8 +173,11 @@ struct PanelSurface::Impl {
for (int x = 32; x < W - 32; ++x) {
const float t = float(x - 32) / (W - 64);
const int y = H - 21 - int(5 * std::sin(t * 3.14159265f));
rect({x, y, 1, 1}, {31, static_cast<unsigned char>(175 - 75 * t),
static_cast<unsigned char>(115 + 80 * t), 255});
Color gradient{};
for (int k = 0; k < 3; ++k)
gradient[k] = static_cast<unsigned char>(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t);
gradient[3] = 255;
rect({x, y, 1, 1}, gradient);
}
}
int advance(uint32_t cp) {
@@ -262,8 +275,8 @@ struct PanelSurface::Impl {
text("FrameYap", 32, 61, 40, ink, 300);
text("ON-DEVICE / REVIEW FIRST", 280, 58, 22, muted, 720);
text(mount_label(mount), 756, 58, 24, cyan, 968);
rounded({32, 78, 936, 48}, 13, {18, 31, 44, 255},
panel.recording ? Color{115, 64, 83, 255} : Color{45, 82, 98, 255},
rounded({32, 78, 936, 48}, 13, mix(background, card, .7f),
panel.recording ? mix(card, pink, .36f) : mix(card, cyan, .22f),
panel.recording ? .16f : 0.f);
rounded({46, 95, 13, 13}, 6, panel.recording ? pink : cyan,
panel.recording ? pink : cyan, panel.recording ? .40f : .20f);
@@ -271,7 +284,7 @@ struct PanelSurface::Impl {
text(status.front(), 70, 109, 27, ink, 844);
if (status.size() > 1) text("[...]", 864, 109, 23, muted, 968);
if (!settings) {
rounded({32, 212, 936, 178}, 16, {22, 34, 49, 255}, {53, 103, 122, 255}, .18f);
rounded({32, 212, 936, 178}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f);
for (size_t i = 0; i < lines_per_page && page * lines_per_page + i < lines.size(); ++i)
text(lines[page * lines_per_page + i], 48, 246 + int(i) * 40, 32,
panel.transcript.empty() ? muted : ink, 952);
@@ -287,30 +300,31 @@ struct PanelSurface::Impl {
32, 486, 23, muted, 968);
text("Tracking lost? Wrist placement falls back to world space.", 32, 518, 23, muted, 968);
}
rect({32, 550, 936, 1}, {39, 65, 80, 255});
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);
bool selected = (b.id == Control::Review && !settings) || (b.id == Control::Settings && settings) ||
(mounting(b.id) && *mounting(b.id) == mount);
const Color fill = !on ? Color{17, 23, 33, 255} :
selected ? Color{26, 55, 68, 255} : card;
const Color fill = !on ? mix(background, card, .40f) :
selected ? mix(card, cyan, .14f) : card;
const Color accent = b.id == Control::Record && panel.recording ? pink : cyan;
const bool highlighted = on && (selected || b.id == Control::Record ||
(b.id == Control::Insert && panel.transcript.size()));
rounded(b.r, std::min(16, b.r.h / 3), fill,
!on ? Color{35, 46, 59, 255} : highlighted ? accent : Color{67, 93, 112, 255},
!on ? mix(card, muted, .13f) : highlighted ? accent : mix(card, muted, .38f),
highlighted ? .23f : 0.f, highlighted ? 2 : 1);
const auto label = b.id == Control::Record && panel.recording ? "Stop" : b.label;
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, 27, on ? ink : Color{81, 96, 113, 255}, b.r.x + b.r.w - 8);
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, 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);
}
dirty = false;
return true;
}
};
PanelSurface::PanelSurface(const std::string& font, Mount mount) : impl_(std::make_unique<Impl>(font, mount)) {}
PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme)
: impl_(std::make_unique<Impl>(font, mount, theme)) {}
PanelSurface::~PanelSurface() = default;
bool PanelSurface::render(const Panel& p) { return impl_->render(p); }
const std::vector<unsigned char>& PanelSurface::pixels() const { return impl_->pixels; }
+2 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "overlay.hpp"
#include "config.hpp"
#include <array>
#include <memory>
#include <optional>
@@ -17,7 +18,7 @@ struct SurfaceEvent {
class PanelSurface {
public:
static constexpr int width = 1000, height = 680;
PanelSurface(const std::string& font, Mount mount);
PanelSurface(const std::string& font, Mount mount, Theme theme = {});
~PanelSurface();
PanelSurface(const PanelSurface&) = delete;
PanelSurface& operator=(const PanelSurface&) = delete;
+61
View File
@@ -0,0 +1,61 @@
#include "config.hpp"
#include <cassert>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <unistd.h>
using namespace frameyap;
namespace {
void put(const std::filesystem::path& p, const std::string& s) { std::ofstream out(p); out << s; assert(out); }
std::string get(const std::filesystem::path& p) { std::ifstream in(p); return {std::istreambuf_iterator<char>(in), {}}; }
template<class Fn> void fails(Fn fn) { bool raised = false; try { fn(); } catch (const std::exception&) { raised = true; } assert(raised); }
}
int main(int argc, char** argv) {
assert(argc == 2);
const auto dir = std::filesystem::temp_directory_path() / ("frameyap-config-test-" + std::to_string(::getpid()));
std::filesystem::create_directories(dir);
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
::setenv("XDG_CACHE_HOME", dir.c_str(), 1);
auto path = default_config_path();
assert(path == dir / "frameyap/config.json");
::setenv("XDG_CONFIG_HOME", "relative", 1);
::setenv("HOME", dir.c_str(), 1);
assert(default_config_path() == dir / ".config/frameyap/config.json");
::unsetenv("HOME");
assert(default_config_path().empty());
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
assert(load_config(path).buttons.empty());
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
assert(example.buttons.at("ptt") == "/user/hand/right/input/x");
assert(example.font.empty());
std::filesystem::create_directories(path.parent_path());
put(path, R"({"theme":{"background":"#123ABC","accent":"#abcdef","frame_end":"#010203"},"font":"/nonexistent/face.ttf","buttons":{"ptt":"/user/hand/left/input/y","cancel":"/user/hand/right/input/b","right_grip":""}})");
auto config = load_config(path);
assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255}));
assert((config.theme.accent == Rgba{0xab, 0xcd, 0xef, 255}));
assert(config.buttons.at("right_grip").empty());
assert(resolve_font(argv[1], config.font) == (std::filesystem::path(argv[1]) / "fonts/Inconsolata-Regular.ttf").string());
auto manifest = action_manifest(argv[1], config);
assert(std::filesystem::is_regular_file(manifest));
assert(get(manifest).find("bindings_frame_controller.json") != std::string::npos);
auto generated = get(manifest.parent_path() / "bindings_frame_controller.json");
assert(generated.find("/user/hand/left/input/y") != std::string::npos);
assert(generated.find("/actions/frameyap/in/cancel") != std::string::npos);
assert(generated.find("/actions/frameyap/in/right_grip") == std::string::npos);
assert(action_manifest(argv[1], {}) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json"));
put(path, R"({"buttons":{"ptt":"/user/hand/left/input/grip"}})");
fails([&] { action_manifest(argv[1], load_config(path)); }); // overlapping bindings are never silently chosen
put(path, R"({"buttons":{"ptt":"/user/hand/left/input/x\"}hack"}})");
fails([&] { load_config(path); });
put(path, R"({"theme":{"ink":"#FG0000"}})");
fails([&] { load_config(path); });
put(path, R"({"font":42})");
fails([&] { load_config(path); });
put(path, R"({"font":"a","font":"b"})");
fails([&] { load_config(path); });
put(path, "{\"font\":\"broken\"");
fails([&] { load_config(path); });
std::filesystem::remove_all(dir);
}
+8
View File
@@ -32,6 +32,14 @@ int main(int argc, char** argv) {
assert(!surface.render(p)); // repeated inactive-input resets do not force uploads
assert(surface.pixels()[3] == 0); // rounded outer corner is transparent
assert(surface.pixels()[(40 * PanelSurface::width + 40) * 4 + 3] == 255);
Theme custom;
custom.background = {75, 30, 100, 255};
custom.accent = {245, 110, 15, 255};
PanelSurface themed(argv[1], Mount::World, custom);
assert(themed.render(p));
const auto pixel = size_t((190 * PanelSurface::width + 500) * 4);
assert(themed.pixels()[pixel] == 75 && themed.pixels()[pixel + 1] == 30 && themed.pixels()[pixel + 2] == 100);
assert(surface.pixels()[pixel] != themed.pixels()[pixel]);
// Disabled controls never emit insertion/submission. Retry/Cancel remain available.
no_action(click(surface, 480, 610)); no_action(click(surface, 680, 610));
no_action(click(surface, 32, 574)); // clipped visual corners are not invisible hit targets
+56 -2
View File
@@ -39,7 +39,8 @@ class InstallTests(unittest.TestCase):
path.write_bytes((name + " fixture\n").encode())
for name in ("bin/frameyap", "runtime/bin/python3", "runtime/bin/helper", "lib/libtest.so"):
(self.stage / name).chmod(0o755)
self.env = patch.dict(os.environ, {"HOME": str(self.home), "XDG_DATA_HOME": str(self.data)})
self.env = patch.dict(os.environ, {"HOME": str(self.home), "XDG_DATA_HOME": str(self.data),
"XDG_CONFIG_HOME": str(self.home / ".config")})
self.env.start()
self.addCleanup(self.env.stop)
self.host = patch.object(installer, "check_host")
@@ -69,6 +70,10 @@ class InstallTests(unittest.TestCase):
self.assertEqual(os.readlink(root / "current"), "versions/v1")
launcher = self.home / ".local/bin/frameyap"
self.assertIn("--run", launcher.read_text())
self.assertNotIn("--font", launcher.read_text()) # run/check modes honor config font
config = self.home / ".config/frameyap/config.json"
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS)
self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text())
self.assertTrue(os.access(root / "versions/v1/runtime/bin/helper", os.X_OK))
self.assertTrue(os.access(root / "versions/v1/lib/libtest.so", os.X_OK))
@@ -106,6 +111,55 @@ class InstallTests(unittest.TestCase):
self.assertFalse(launcher.exists())
self.assertFalse(desktop.exists())
def test_config_install_repairs_and_preserves_original(self):
archive, digest = self.package("v1")
config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True)
original = b'{"font":"/system/face.ttf","theme":{"ink":"#F1f2F3"},"buttons":{"ptt":"/user/hand/left/input/y"}}\n'
config.write_bytes(original)
self.install("v1", archive, digest)
fixed = json.loads(config.read_text())
self.assertEqual(fixed["font"], "/system/face.ttf")
self.assertEqual(fixed["theme"]["ink"], "#F1f2F3")
self.assertEqual(fixed["buttons"]["ptt"], "/user/hand/left/input/y")
self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"])
self.assertEqual(fixed["buttons"]["cancel"], "")
backups = list(config.parent.glob("config.json.backup-*"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), original)
compact = json.dumps(fixed, separators=(",", ":")).encode()
config.write_bytes(compact)
self.install("v1", archive, digest)
self.assertEqual(config.read_bytes(), compact)
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
original = b'{"font":"/system/face.ttf","theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}'
config.write_bytes(original)
self.install("v1", archive, digest)
fixed = json.loads(config.read_text())
self.assertEqual(fixed["font"], "/system/face.ttf")
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
self.assertNotIn("old_option", fixed)
self.assertEqual(sorted(p.read_bytes() for p in config.parent.glob("config.json.backup-*")), sorted([backups[0].read_bytes(), original]))
original = b'{"font":"one","font":"two"'
config.write_bytes(original)
self.install("v1", archive, digest)
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS)
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
config.write_text(json.dumps({"font": "/" + "x" * 3800}))
self.install("v1", archive, digest)
self.assertEqual(json.loads(config.read_text())["font"], "")
self.assertTrue(all(len(p.read_bytes()) > 0 for p in config.parent.glob("config.json.backup-*")))
config.write_bytes(b"x" * 65537)
with self.assertRaisesRegex(ValueError, "too large"):
self.install("v1", archive, digest)
self.assertEqual(config.stat().st_size, 65537)
config.unlink()
config.symlink_to(self.stage / "model/weights.bin")
with self.assertRaisesRegex(ValueError, "foreign config path"):
self.install("v1", archive, digest)
self.assertTrue(config.is_symlink())
def test_digest_and_same_version_mismatch_leave_previous(self):
a, h = self.package("v1")
self.install("v1", a, h)
@@ -306,7 +360,7 @@ class InstallTests(unittest.TestCase):
self.install("external", a, h)
self.assertEqual(launcher.read_bytes(), installer.desired_launcher(root))
config = self.home / ".config/frameyap/paths.conf"
config.parent.mkdir(parents=True)
config.parent.mkdir(parents=True, exist_ok=True)
config.write_text("# Literal paths, not shell code\npython=/opt/approved python/bin/python3\n"
"model=/opt/$(printf not-executed)/local model\n")
run = subprocess.run([str(launcher)], capture_output=True, text=True, check=True)