diff --git a/CMakeLists.txt b/CMakeLists.txt index 44427c3..4ce027b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md index ac43d61..ba9ed1b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/config.example.json b/assets/config.example.json new file mode 100644 index 0000000..97b63c3 --- /dev/null +++ b/assets/config.example.json @@ -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": "" + } +} diff --git a/docs/overlay.md b/docs/overlay.md index ae7cf9d..1d40e6d 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -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, diff --git a/docs/packaging.md b/docs/packaging.md index b045fbf..f152759 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -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. diff --git a/install.sh b/install.sh index e447e95..c6db559 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/scripts/install_payload.py b/scripts/install_payload.py index ffcbc2a..4748fdd 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -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" diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..f0f1c9b --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,249 @@ +#include "config.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace frameyap { +namespace { +struct Json { + std::string value; + std::map 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(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(s[pos]))) ++pos; } + if (pos < s.size() && s[pos] == '.') { ++pos; size_t at = pos; while (pos < s.size() && std::isdigit(static_cast(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(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(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 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 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 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 diff --git a/src/config.hpp b/src/config.hpp new file mode 100644 index 0000000..a758946 --- /dev/null +++ b/src/config.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include + +namespace frameyap { +using Rgba = std::array; +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 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 diff --git a/src/main.cpp b/src/main.cpp index deacb09..9354f3f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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" diff --git a/src/overlay.cpp b/src/overlay.cpp index 23bb554..eb7e739 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -51,6 +51,7 @@ struct Overlay::Impl { std::array 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 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(); diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 61d8dd9..e11f18f 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -11,10 +11,7 @@ namespace frameyap { namespace { constexpr int W = PanelSurface::width, H = PanelSurface::height; -using Color = std::array; -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 pixels = std::vector(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 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(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(255 - 143 * t), - static_cast(145 + 110 * t), 255}; + Color gradient{}; + for (int k = 0; k < 3; ++k) + gradient[k] = static_cast(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(background[k] + (gradient[k] - background[k]) * strength); dst[3] = distance <= 1.f ? 255 : static_cast(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(175 - 75 * t), - static_cast(115 + 80 * t), 255}); + Color gradient{}; + for (int k = 0; k < 3; ++k) + gradient[k] = static_cast(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(font, mount)) {} +PanelSurface::PanelSurface(const std::string& font, Mount mount, Theme theme) + : impl_(std::make_unique(font, mount, theme)) {} PanelSurface::~PanelSurface() = default; bool PanelSurface::render(const Panel& p) { return impl_->render(p); } const std::vector& PanelSurface::pixels() const { return impl_->pixels; } diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index b99b410..d3328b7 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -1,5 +1,6 @@ #pragma once #include "overlay.hpp" +#include "config.hpp" #include #include #include @@ -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; diff --git a/tests/config_test.cpp b/tests/config_test.cpp new file mode 100644 index 0000000..d9a4b1f --- /dev/null +++ b/tests/config_test.cpp @@ -0,0 +1,61 @@ +#include "config.hpp" +#include +#include +#include +#include +#include +#include +#include +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(in), {}}; } +template 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); +} diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 84bd7a2..863e18b 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -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 diff --git a/tests/test_installer.py b/tests/test_installer.py index daa1e37..c2ce0f8 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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)