mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 01:00:04 +02:00
Add XDG JSON theme, font and button configuration with installer repair
This commit is contained in:
1 parent
1684040abe
commit
1496e436eb
16 files changed
+717
-46
No files matched your search
+6
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user