feat(installer): add attended source installs and consent-bound model provisioning

This commit is contained in:
baketnk committed 2026-09-24 22:32:51 -04:00
1 parent 5cbbda3ec2
commit 4a2c524b17
8 files changed
+1563 -278

No files matched your search

+457 -55
View File
@@ -1,16 +1,29 @@
#!/bin/sh #!/bin/sh
# FrameYap pinned release installer. No implicit version, downloads, or runtime launch. # FrameYap pinned release installer. No implicit version, downloads, or runtime launch.
set -eu set -eu
for tool in python3 curl sha256sum tar; do json=false
command -v "$tool" >/dev/null 2>&1 || { echo "frameyap installer: missing prerequisite: $tool" >&2; exit 1; } for arg in "$@"; do [ "$arg" = --json ] && json=true; done
done bootstrap_error() {
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' || { if [ "$json" = true ]; then
echo 'frameyap installer: Python 3.12+ required for bootstrap only (release bundles runtime)' >&2 printf '{"ok":false,"code":"bootstrap","message":"%s","exit_code":1}\n' "$1"
else
printf 'frameyap installer: %s\n' "$1" >&2
fi
exit 1 exit 1
} }
exec python3 - "$@" <<'PY' command -v python3 >/dev/null 2>&1 || bootstrap_error 'missing prerequisite: python3'
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' || {
bootstrap_error 'Python 3.12+ required for bootstrap only'
}
# Keep the original invocation's stdin on fd 3; the heredoc supplies only Python code.
# The payload uses fd 3 for prompts only if both input and output are real TTYs.
exec python3 - "$@" 3<&0 <<'PY'
"""FrameYap installer implementation. Embedded verbatim in install.sh for piped installs.""" """FrameYap installer implementation. Embedded verbatim in install.sh for piped installs."""
import argparse import argparse
import contextlib
from datetime import datetime
import io
import importlib.util
import fcntl import fcntl
import hashlib import hashlib
import json import json
@@ -18,24 +31,28 @@ import os
from pathlib import Path from pathlib import Path
import platform import platform
import re import re
import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tarfile import tarfile
import tempfile import tempfile
from urllib.parse import quote from urllib.parse import quote
import urllib.request
KEY = "local.frameyap.overlay" KEY = "local.frameyap.overlay"
MARKER = "# FrameYap managed launcher v1\n" MARKER = "# FrameYap managed launcher v1\n"
ARCHIVE_LIMIT = 12 * 1024**3 ARCHIVE_LIMIT = 12 * 1024**3
MEMBER_LIMIT = 50000 MEMBER_LIMIT = 50000
VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z") VERSION_RE = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]{11}\Z")
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z") DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = { CONFIG_DEFAULTS = {
"font": "", "font": "",
"input_priority": "normal", "input_priority": "normal",
"advanced_debug": False, "advanced_debug": False,
"auto_insert": False, "auto_insert": False,
"close_mic_when_idle": False,
"backend": "redux",
"lock_layout": False, "lock_layout": False,
"clock_24h": False, "clock_24h": False,
"date_format": "mdy", "date_format": "mdy",
@@ -52,6 +69,7 @@ CONFIG_DEFAULTS = {
} }
COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z") 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") BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z")
BACKEND_RE = re.compile(r"[a-z][a-z0-9_-]{0,47}\Z", re.ASCII)
def fail(message): def fail(message):
@@ -103,6 +121,10 @@ def normalized_config(data):
fixed["advanced_debug"] = debug if type(debug) is bool else False fixed["advanced_debug"] = debug if type(debug) is bool else False
automatic = data.get("auto_insert", False) automatic = data.get("auto_insert", False)
fixed["auto_insert"] = automatic if type(automatic) is bool else False fixed["auto_insert"] = automatic if type(automatic) is bool else False
close_mic = data.get("close_mic_when_idle", False)
fixed["close_mic_when_idle"] = close_mic if type(close_mic) is bool else False
backend = data.get("backend", "redux")
fixed["backend"] = backend if isinstance(backend, str) and BACKEND_RE.fullmatch(backend) else "redux"
layout = data.get("lock_layout", False) layout = data.get("lock_layout", False)
fixed["lock_layout"] = layout if type(layout) is bool else False fixed["lock_layout"] = layout if type(layout) is bool else False
clock = data.get("clock_24h", False) clock = data.get("clock_24h", False)
@@ -204,8 +226,12 @@ def check_digest(value):
def check_version(value): def check_version(value):
if not VERSION_RE.fullmatch(value) or value in (".", ".."): if not VERSION_RE.fullmatch(value):
fail("invalid release version/tag") fail("version must be numeric MAJOR.MINOR.YYYYMMDDHHMM (no leading zeros, v prefix or git suffix)")
try:
datetime.strptime(value.rsplit(".", 1)[1], "%Y%m%d%H%M")
except ValueError:
fail("invalid UTC date/time in version")
return value return value
@@ -246,7 +272,7 @@ def verify_members(archive):
total += member.size total += member.size
if total > ARCHIVE_LIMIT: if total > ARCHIVE_LIMIT:
fail("archive uncompressed limit exceeded") fail("archive uncompressed limit exceeded")
if (parts[0] not in ("release.json", "bin", "lib", "assets", "python", "runtime", "model", "fonts", "licenses") if (parts[0] not in ("release.json", "bin", "lib", "assets", "python", "scripts", "runtime", "model", "fonts", "licenses")
or (parts[0] == "release.json" and (len(parts) != 1 or not member.isfile())) or (parts[0] == "release.json" and (len(parts) != 1 or not member.isfile()))
or (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())): or (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())):
fail(f"unexpected archive path: {name}") fail(f"unexpected archive path: {name}")
@@ -284,9 +310,11 @@ def validate_payload(root, version, without_model=False, installed=False):
fail("missing declared model") fail("missing declared model")
if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists(): if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists():
fail("external runtime payload must not include a runtime") fail("external runtime payload must not include a runtime")
for name in ("bin/frameyap", "runtime/bin/python3", "python/frameyap/worker.py", "assets/actions.json", "fonts/font.ttf"): for name in ("bin/frameyap", "bin/install.sh", "runtime/bin/python3", "python/frameyap/worker.py", "assets/actions.json", "fonts/font.ttf"):
if name == "runtime/bin/python3" and meta["runtime"] == "external-authorized-python": if name == "runtime/bin/python3" and meta["runtime"] == "external-authorized-python":
continue continue
if name == "bin/install.sh" and installed and not VERSION_RE.fullmatch(version):
continue # legacy installed release, before a managed UI child existed
if not (root / name).is_file(): if not (root / name).is_file():
fail(f"missing payload file: {name}") fail(f"missing payload file: {name}")
for name in ("lib", "fonts"): for name in ("lib", "fonts"):
@@ -448,6 +476,194 @@ def installed_choice(path):
return choice["without_model"] return choice["without_model"]
class ManifestMismatch(ValueError):
"""The installed manifest does not match the UI's selected metadata."""
def manifest_digest(path):
"""Hash the exact installed ID.json bytes, never a symlink or directory."""
import stat
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > 65536:
fail("installed backend manifest is oversized or unsafe")
raw = stream.read(65537)
if len(raw) > 65536:
fail("installed backend manifest is oversized or unsafe")
return hashlib.sha256(raw).hexdigest()
def installed_backend(root, backend_id):
current = selected(root, "current")
if not current:
fail("no installed release; install a verified archive before provisioning models")
version = root / current
installed_choice(version)
check_inventory(version)
module_path = version / "python/frameyap/model_files.py"
if module_path.is_symlink() or not module_path.is_file():
fail("installed release has no shared backend manifest verifier")
if module_path.stat().st_size > 262144:
fail("installed backend verifier exceeds size limit")
spec = importlib.util.spec_from_file_location("frameyap_installed_model_files", module_path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
# Executing compiled bytes avoids __pycache__ inside the inventory-protected
# installed release; a machine-readable installer must not mutate it.
exec(compile(module_path.read_bytes(), str(module_path), "exec"), module.__dict__)
manifests = module.load_backends(version / "assets/backends")
if backend_id not in manifests:
fail(f"unknown backend: {backend_id}; available: {', '.join(sorted(manifests))}")
# load_backends enforces filename == manifest id. Hash only that selected file,
# not all manifests or their parsed/reformatted representation.
digest = manifest_digest(version / "assets/backends" / (backend_id + ".json"))
return module, manifests[backend_id], digest
def check_expected_manifest(args, installed_sha):
if args.expected_manifest_sha256 and args.expected_manifest_sha256.lower() != installed_sha:
raise ManifestMismatch(f"installed {args.backend}.json manifest SHA-256 mismatch: "
f"expected {args.expected_manifest_sha256.lower()}, got {installed_sha}")
def model_target(args, root):
if args.model_dir:
if not args.model_dir.is_absolute():
fail("--model-dir must be an absolute local path")
return args.model_dir
return root / "models" / args.backend
def install_model(args, root):
module, backend, manifest_sha = installed_backend(root, args.backend)
check_expected_manifest(args, manifest_sha) # under .model.lock, before model mkdir/network
dest = model_target(args, root)
# No credentials, redirects to HTTP, symlinks or overwrite of mismatched files.
for ancestor in (dest, *dest.parents):
if ancestor.is_symlink():
fail(f"refusing symlink in model directory path: {ancestor}")
owned_dir(dest)
if not backend.source.startswith("https://"):
fail("model source must be HTTPS")
for item in backend.files:
target = dest / item.path
owned_dir(target.parent)
reason, _ = module.check_file(dest, item)
if reason is None:
if args.json:
print(json.dumps({"ok": True, "event": "model_file", "file": item.path, "state": "verified"}), flush=True)
continue
if reason not in ("missing_files",):
fail(f"refusing mismatched or unsafe model file: {target} ({reason})")
url = f"{backend.source}/resolve/{quote(backend.revision, safe='')}/{quote(item.path, safe='/')}"
if args.json:
print(json.dumps({"ok": True, "event": "model_file", "file": item.path, "state": "downloading", "bytes": item.size}), flush=True)
fd, temp = tempfile.mkstemp(prefix=".download-", dir=target.parent)
try:
with os.fdopen(fd, "wb") as output:
# The shared verifier is used for both pre-existing and downloaded files.
request = urllib.request.Request(url, headers={"User-Agent": "frameyap-installer"})
with urllib.request.urlopen(request, timeout=60) as response:
if response.geturl().split(":", 1)[0] != "https":
fail("model URL redirected away from HTTPS")
total = 0
while chunk := response.read(1024 * 1024):
total += len(chunk)
if total > item.size:
fail(f"model download exceeded pinned size: {item.path}")
output.write(chunk)
output.flush()
os.fsync(output.fileno())
temporary = type(item)(Path(temp).name, item.size, item.sha256)
if module.check_file(target.parent, temporary)[0] is not None:
fail(f"pinned SHA-256/size mismatch: {item.path}")
if target.exists() or target.is_symlink():
fail(f"model destination changed during download: {target}")
os.replace(temp, target)
if args.json:
print(json.dumps({"ok": True, "event": "model_file", "file": item.path, "state": "verified"}), flush=True)
finally:
Path(temp).unlink(missing_ok=True)
state = module.check_model(backend, dest)
if state["state"] != "installed_verified":
fail(f"model did not verify: {state['reason']}")
if not args.json:
print(f"Backend {backend.id} model verified at {dest}; license {backend.license_id}. Configure runtime/model paths explicitly for launch.")
def source_preflight(args):
"""Read-only checks before the installer creates its install root."""
source = Path(args.source).expanduser().resolve(strict=True)
if not source.is_dir() or not (source / "CMakeLists.txt").is_file():
fail("--source must name a local FrameYap source directory")
for script in ("scripts/install-preflight.sh", "scripts/stage-native.py", "scripts/package-release.py"):
if not (source / script).is_file() or (source / script).is_symlink():
fail(f"source missing regular packaging script: {script}")
sdk = Path(args.openvr_root).expanduser().resolve(strict=True)
if not (sdk / "headers/openvr.h").is_file():
fail("--openvr-root must contain headers/openvr.h")
inputs = {}
for name in ("openvr_library", "openvr_license", "sdl_library", "sdl_license"):
path = Path(getattr(args, name)).expanduser().resolve(strict=True)
if not path.is_file():
fail(f"--{name.replace('_', '-')} must be a local file")
inputs[name] = str(path)
for tool in ("cmake", "c++", "pkg-config", "wayland-scanner"):
if not shutil.which(tool):
fail(f"source prerequisite missing: {tool}; no download/package install attempted")
preflight = subprocess.run(["sh", str(source / "scripts/install-preflight.sh"), "--source"],
capture_output=True, text=True, check=False)
if preflight.returncode:
fail(f"source preflight failed (exit {preflight.returncode}): {preflight.stderr[-2000:]}")
for dep in ("sdl3", "wayland-client", "xcb", "freetype2", "vulkan"):
if subprocess.run(["pkg-config", "--exists", dep], check=False).returncode:
fail(f"source dependency missing: {dep}; provide local native dependencies")
return source, sdk, inputs
def source_model_revision(source):
"""Use the explicit source tree's pinned manifest, not a frozen installer hash."""
manifest = source / "assets/backends/redux.json"
if manifest.is_symlink() or not manifest.is_file() or manifest.stat().st_size > 65536:
fail("source missing safe pinned Redux backend manifest")
data = json.loads(manifest.read_text())
if not isinstance(data, dict) or data.get("id") != "redux" or not isinstance(data.get("model"), dict):
fail("invalid source Redux backend manifest")
revision = data["model"].get("revision")
if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
fail("invalid pinned Redux model revision in source manifest")
return revision
def source_archive(args, work):
"""Build only explicitly supplied local sources/dependencies in private work dir."""
source, sdk, inputs = source_preflight(args)
revision = source_model_revision(source)
build = work / "build"
stage = work / "stage"
output = work / "out"
output.mkdir(mode=0o700)
commands = [
["cmake", "-S", str(source), "-B", str(build), "-DFRAMEYAP_NATIVE=ON",
f"-DOPENVR_ROOT={sdk}", f"-DFRAMEYAP_VERSION={args.version}"],
["cmake", "--build", str(build)],
[sys.executable, str(source / "scripts/stage-native.py"), "--build", str(build),
"--destination", str(stage), *[x for name in inputs for x in ("--" + name.replace("_", "-"), inputs[name])]],
[sys.executable, str(source / "scripts/package-release.py"), "--stage", str(stage),
"--output", str(output), "--arch", "linux-aarch64", "--version", args.version,
"--model-revision", revision, "--external-runtime"],
]
for cmd in commands:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode:
fail(f"source command failed ({cmd[0]}, exit {result.returncode}): {result.stderr[-2000:]}")
archive = output / release_name(args.version)
if not archive.is_file():
fail("source packaging did not produce the expected release archive")
return archive, digest_file(archive)
def do_install(args, root, launcher): def do_install(args, root, launcher):
version = check_version(args.version) version = check_version(args.version)
versions = root / "versions" versions = root / "versions"
@@ -457,17 +673,20 @@ def do_install(args, root, launcher):
# Refuse foreign wrappers/launcher directories before installing a new version. # Refuse foreign wrappers/launcher directories before installing a new version.
owned_dir(launcher.parent) owned_dir(launcher.parent)
check_wrappers(root, launcher) check_wrappers(root, launcher)
if args.archive:
archive = Path(args.archive).expanduser().resolve(strict=True)
expected = check_digest(args.sha256)
do_download = False
else:
do_download = True
archive = None
with tempfile.TemporaryDirectory(prefix=".download-", dir=root) as td: with tempfile.TemporaryDirectory(prefix=".download-", dir=root) as td:
if args.source:
archive, expected = source_archive(args, Path(td))
do_download = False
elif args.archive:
archive = Path(args.archive).expanduser().resolve(strict=True)
expected = check_digest(args.sha256)
do_download = False
else:
do_download = True
archive = None
if do_download: if do_download:
name = release_name(version) name = release_name(version)
url = f"https://github.com/{args.repo}/releases/download/{quote(version)}/{name}" url = f"https://github.com/{args.repo}/releases/download/{quote('v' + version)}/{name}"
archive = Path(td) / name archive = Path(td) / name
sidecar = Path(td) / (name + ".sha256") sidecar = Path(td) / (name + ".sha256")
download(url + ".sha256", sidecar) download(url + ".sha256", sidecar)
@@ -563,68 +782,251 @@ def uninstall(root, launcher):
print("FrameYap removed; config and saved models preserved. OpenVR unregister acknowledgement was required.") print("FrameYap removed; config and saved models preserved. OpenVR unregister acknowledgement was required.")
def main(argv=None): class UsageError(ValueError):
parser = argparse.ArgumentParser(prog="install.sh", description="User-local FrameYap release installer (no SteamVR actions)") pass
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--archive", help="local release archive (requires --sha256 and --version)")
mode.add_argument("--rollback", action="store_true") class InstallerParser(argparse.ArgumentParser):
mode.add_argument("--uninstall", action="store_true") def error(self, message):
raise UsageError(message)
def resolve_args(argv):
parser = InstallerParser(prog="install.sh", description="User-local FrameYap installer; never launches the overlay")
action = parser.add_mutually_exclusive_group()
action.add_argument("--archive", help="local release archive (requires --sha256 and --version)")
action.add_argument("--rollback", action="store_true")
action.add_argument("--uninstall", action="store_true")
action.add_argument("--install-model", action="store_true", help="explicit model provisioning for installed backend")
parser.add_argument("--mode", choices=("binary", "source"), default="binary")
parser.add_argument("--source", help="explicit local FrameYap source tree (source mode only)")
parser.add_argument("--openvr-root", help="local OpenVR SDK root (source mode)")
for name in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
parser.add_argument("--" + name, help="explicit local source packaging input")
parser.add_argument("--sha256") parser.add_argument("--sha256")
parser.add_argument("--version") parser.add_argument("--version", help="numeric MAJOR.MINOR.YYYYMMDDHHMM; GitHub tag is vVERSION")
parser.add_argument("--repo", default="baketnk/frame-yap") parser.add_argument("--repo", default="baketnk/frame-yap")
parser.add_argument("--backend", default="redux")
parser.add_argument("--model-dir", type=Path)
parser.add_argument("--expected-manifest-sha256", help="SHA-256 of selected installed backend ID.json bytes")
parser.add_argument("--yes", action="store_true", help="explicit consent to network/model provisioning")
parser.add_argument("--autolaunch", dest="autolaunch", action="store_true", default=None)
parser.add_argument("--no-autolaunch", dest="autolaunch", action="store_false")
parser.add_argument("--without-model", action="store_true") parser.add_argument("--without-model", action="store_true")
parser.add_argument("--print-plan", action="store_true", help="read-only plan; no lock, download or install")
parser.add_argument("--json", action="store_true", help="one JSON result or error on stdout")
parser.add_argument("--unregistered", action="store_true", help="acknowledge explicit OpenVR removal before uninstall") parser.add_argument("--unregistered", action="store_true", help="acknowledge explicit OpenVR removal before uninstall")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.repo != "baketnk/frame-yap": if args.repo != "baketnk/frame-yap":
parser.error("only the pinned baketnk/frame-yap release repository is supported") parser.error("only the pinned baketnk/frame-yap release repository is supported")
source_inputs = ("openvr_root", "openvr_library", "openvr_license", "sdl_library", "sdl_license")
if args.mode == "source":
if not args.source or any(not getattr(args, name) for name in source_inputs):
parser.error("--mode source requires --source and explicit --openvr-root, --openvr-library, --openvr-license, --sdl-library, --sdl-license")
if args.archive or args.sha256 or args.rollback or args.uninstall or args.install_model:
parser.error("source mode cannot combine with binary archive or lifecycle operations")
elif args.source or any(getattr(args, name) for name in source_inputs):
parser.error("source inputs require --mode source")
if args.archive and (not args.sha256 or not args.version): if args.archive and (not args.sha256 or not args.version):
parser.error("--archive requires --sha256 and --version") parser.error("--archive requires --sha256 and --version")
if args.archive and not DIGEST_RE.fullmatch(args.sha256):
parser.error("--sha256 must be a 64-character hex SHA-256")
if not args.archive and args.sha256: if not args.archive and args.sha256:
parser.error("--sha256 only applies to --archive") parser.error("--sha256 only applies to --archive")
if not (args.rollback or args.uninstall or args.archive) and not args.version: if not (args.rollback or args.uninstall or args.install_model) and not args.version:
parser.error("--version TAG is required; no moving/latest release") parser.error("--version VERSION is required; no moving/latest release")
if args.uninstall and not args.unregistered: if args.uninstall != args.unregistered:
parser.error("uninstall requires --unregistered after explicit OpenVR unregister") parser.error("--uninstall requires --unregistered after explicit OpenVR unregister")
if args.unregistered and not args.uninstall: if args.model_dir and not args.install_model:
parser.error("--unregistered only applies to --uninstall") parser.error("--model-dir applies only to --install-model")
if args.expected_manifest_sha256 is not None:
if not args.install_model:
parser.error("--expected-manifest-sha256 applies only to --install-model")
if not DIGEST_RE.fullmatch(args.expected_manifest_sha256):
parser.error("--expected-manifest-sha256 must be a 64-character hex SHA-256")
if args.model_dir and not args.model_dir.is_absolute():
parser.error("--model-dir must be an absolute local path")
if args.backend != "redux" and not args.install_model:
parser.error("--backend ID currently applies only to --install-model; select the active backend in FrameYap settings")
if args.install_model and (args.without_model or args.version or args.archive or args.autolaunch is not None):
parser.error("--install-model uses the installed version; cannot combine with archive/version/without-model/autolaunch")
if (args.rollback or args.uninstall) and (args.version or args.without_model or args.autolaunch is not None or args.backend != "redux"):
parser.error("lifecycle actions cannot combine with installation/model flags")
if args.version: if args.version:
check_version(args.version) try:
check_version(args.version)
except ValueError as error:
parser.error(str(error))
return args
def plan(args):
operation = ("uninstall" if args.uninstall else "rollback" if args.rollback else
"install-model" if args.install_model else "install")
return {"operation": operation, "mode": args.mode, "version": args.version,
"tag": "v" + args.version if args.version else None,
"archive": str(args.archive) if args.archive else None,
"source": str(args.source) if args.source else None,
"backend": args.backend, "model_dir": str(args.model_dir) if args.model_dir else None,
"expected_manifest_sha256": (args.expected_manifest_sha256.lower() if args.expected_manifest_sha256 else None),
"without_model": args.without_model, "autolaunch": args.autolaunch,
"network": bool(args.install_model or (operation == "install" and not args.archive and not args.source)),
"registration": ("explicit --register --autostart" if args.autolaunch is True else
"explicit --register (autostart off)" if args.autolaunch is False else "none")}
def main(argv=None):
args = resolve_args(argv)
if args.print_plan:
result = plan(args)
if args.install_model:
data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute()
root = data / "frameyap"
_, backend, manifest_sha = installed_backend(root, args.backend)
check_expected_manifest(args, manifest_sha)
result.update(model_dir=str(model_target(args, root)), model=backend.description(),
installed_manifest_sha256=manifest_sha)
if args.json:
print(json.dumps({"ok": True, "event": "plan", **result}, sort_keys=True))
else:
print(json.dumps(result, indent=2, sort_keys=True))
return
check_host() check_host()
if args.mode == "source":
source_preflight(args)
if (not args.archive and not args.source and not args.rollback and not args.uninstall) and not args.yes:
fail("network/model install requires explicit --yes (no stdin prompts); use --print-plan first")
data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute() data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute()
root = data / "frameyap" root = data / "frameyap"
launcher = Path.home() / ".local/bin/frameyap" launcher = Path.home() / ".local/bin/frameyap"
owned_dir(root) owned_dir(root)
lock = root / ".lock" # UI child model provisioning must work while the overlay holds .lock.
# Models live outside versions and never replace a running executable.
lock = root / (".model.lock" if args.install_model else ".lock")
if lock.is_symlink(): if lock.is_symlink():
fail("refusing symlink lock") fail("refusing symlink lock")
with lock.open("a+b") as fd: with lock.open("a+b") as fd:
try: try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
fail("FrameYap installer or application is running (.lock held); try again later") fail(f"FrameYap installer or application is running ({lock.name} held); try again later")
if args.uninstall: other = root / ".model.lock"
uninstall(root, launcher) if not args.install_model and other.is_symlink():
elif args.rollback: fail("refusing symlink model lock")
owned_dir(root / "versions") with (contextlib.nullcontext() if args.install_model else other.open("a+b")) as model_fd:
current, previous = selected(root, "current"), selected(root, "previous") if model_fd is not None:
if not current or not previous: try:
fail("no previous installation to roll back to") fcntl.flock(model_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
check_wrappers(root, launcher) except BlockingIOError:
choice = installed_choice(root / previous) fail("model provisioning is running (.model.lock held); retry later")
check_inventory(root / previous) if args.uninstall:
validate_payload(root / previous, Path(previous).name, choice, installed=True) uninstall(root, launcher)
select(root, "current", previous) elif args.rollback:
select(root, "previous", current) owned_dir(root / "versions")
print(f"Rolled back to {previous}") current, previous = selected(root, "current"), selected(root, "previous")
if not current or not previous:
fail("no previous installation to roll back to")
check_wrappers(root, launcher)
choice = installed_choice(root / previous)
check_inventory(root / previous)
validate_payload(root / previous, Path(previous).name, choice, installed=True)
select(root, "current", previous)
select(root, "previous", current)
print(f"Rolled back to {previous}")
elif args.install_model:
install_model(args, root)
else:
do_install(args, root, launcher)
# The native registration helper takes this same install lock. Never run it
# until the installer has released its own lock; never initialize OpenVR by default.
if args.autolaunch is not None:
command = [str(launcher), "--register", str(root / "frameyap.vrmanifest")]
if args.autolaunch:
command.append("--autostart")
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode:
fail(f"files installed, but opt-in OpenVR autolaunch registration failed (exit {result.returncode}): {result.stderr[-1000:]}")
print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request")
def interactive_options():
"""Only an empty, actual terminal invocation offers a guided local choice."""
print("FrameYap installer: choose binary (verified archive) or source (local build).")
mode = input("Mode [binary/source]: ").strip().lower()
if mode not in ("binary", "source"):
raise UsageError("choose binary or source; no installation started")
version = input("Numeric release version (MAJOR.MINOR.YYYYMMDDHHMM): ").strip()
check_version(version)
chosen = ["--mode", mode, "--version", version]
if mode == "binary":
archive = input("Local archive path (leave empty for pinned GitHub release): ").strip()
if archive:
chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()]
else: else:
do_install(args, root, launcher) if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes":
raise UsageError("download not approved; no installation started")
chosen.append("--yes")
else:
for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"):
chosen += ["--" + flag, input(flag + " local path: ").strip()]
if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes":
chosen.append("--without-model")
if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes":
chosen.append("--autolaunch")
print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen))
return chosen
def cli(argv=None):
argv = sys.argv[1:] if argv is None else argv
structured = "--json" in argv
if not argv and sys.stdout.isatty():
# With `sh install.sh`, fd 0 is the embedded Python code, not the
# invoking terminal. fd 3 retains original stdin (including a pipe).
attended = sys.stdin
if not attended.isatty():
try:
if os.isatty(3):
attended = os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
if attended.isatty():
try:
original_stdin = sys.stdin
try:
sys.stdin = attended
argv = interactive_options()
finally:
sys.stdin = original_stdin
except (ValueError, EOFError) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr)
return 2
try:
if structured and "--print-plan" in argv:
main(argv) # already emits one JSON plan
elif structured and "--install-model" in argv:
main(argv) # streaming per-file JSON events for a UI child
print(json.dumps({"ok": True, "event": "complete"}))
elif structured:
capture = io.StringIO()
with contextlib.redirect_stdout(capture):
main(argv)
print(json.dumps({"ok": True, "event": "complete", "messages": capture.getvalue().splitlines()}))
else:
main(argv)
return 0
except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError, ImportError) as exc:
code = 2 if isinstance(exc, UsageError) else 1
if structured:
print(json.dumps({"ok": False, "code": ("usage" if code == 2 else
"manifest_mismatch" if isinstance(exc, ManifestMismatch) else "operation_failed"),
"message": str(exc), "exit_code": code}))
else:
print(f"frameyap installer: {exc}", file=sys.stderr)
return code
if __name__ == "__main__": if __name__ == "__main__":
try: sys.exit(cli())
main()
except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr)
sys.exit(1)
PY PY
+42 -13
View File
@@ -1,15 +1,20 @@
#!/bin/sh #!/bin/sh
# Read-only host check for the proposed release installer. Does not install anything. # Read-only host check for binary/source installation. Does not install anything.
set -u set -u
mode=binary
if [ "$#" -ne 0 ]; then if [ "$#" -ne 0 ]; then
if [ "$#" -eq 1 ] && [ "$1" = --help ]; then if [ "$#" -eq 1 ] && [ "$1" = --help ]; then
printf '%s\n' 'Usage: sh scripts/install-preflight.sh' \ printf '%s\n' 'Usage: sh scripts/install-preflight.sh [--source]' \
'Checks the proposed Linux ARM64 installation prerequisites; makes no changes.' 'Read-only Linux ARM64 binary/source dependency check; never installs anything.'
exit 0 exit 0
fi fi
printf '%s\n' 'No arguments are supported (except --help).' >&2 if [ "$#" -eq 1 ] && [ "$1" = --source ]; then
exit 2 mode=source
else
printf '%s\n' 'Only --source or --help is supported.' >&2
exit 2
fi
fi fi
errors=0 errors=0
@@ -32,8 +37,9 @@ case "$libc" in
errors=1 ;; errors=1 ;;
esac esac
# Expected for a future curl/tar release bootstrap, not for building from source. # curl is needed for an explicitly approved GitHub download; local archives do
for dep in curl tar sha256sum mktemp mkdir mv; do # not require it. The Python bootstrap handles tar/SHA-256 for both modes.
for dep in python3; do
if command -v "$dep" >/dev/null 2>&1; then if command -v "$dep" >/dev/null 2>&1; then
printf 'Required tool: %s found\n' "$dep" printf 'Required tool: %s found\n' "$dep"
else else
@@ -55,25 +61,48 @@ fi
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
version=$(python3 --version 2>&1) || version=unknown version=$(python3 --version 2>&1) || version=unknown
printf 'System Python: %s (not required for a bundled runtime)\n' "$version" printf 'System Python: %s (3.12+ required for installer bootstrap)\n' "$version"
case "$version" in case "$version" in
'Python 3.'*) 'Python 3.'*)
minor=${version#Python 3.} minor=${version#Python 3.}
minor=${minor%%.*} minor=${minor%%.*}
case "$minor" in case "$minor" in
''|*[!0-9]*) printf '%s\n' 'System Python version could not be parsed.' ;; ''|*[!0-9]*) printf '%s\n' 'System Python version could not be parsed.' >&2; errors=1 ;;
*) if [ "$minor" -lt 12 ]; then *) if [ "$minor" -lt 12 ]; then
printf '%s\n' 'System Python is older than 3.12; unsuitable for the proposed optional source worker.' printf '%s\n' 'System Python is older than 3.12; installer bootstrap requires 3.12+.' >&2
errors=1
fi ;; fi ;;
esac ;; esac ;;
*) printf '%s\n' 'System Python version could not be parsed.' ;; *) printf '%s\n' 'System Python version could not be parsed.' >&2; errors=1 ;;
esac esac
else else
printf '%s\n' 'System Python: missing (not required for a bundled runtime)' printf '%s\n' 'System Python: missing (installer bootstrap requires 3.12+)' >&2
errors=1
fi
if [ "$mode" = source ]; then
for dep in cmake c++ pkg-config wayland-scanner; do
if command -v "$dep" >/dev/null 2>&1; then
printf 'Source tool: %s found\n' "$dep"
else
printf 'Source tool: %s MISSING\n' "$dep" >&2
errors=1
fi
done
if command -v pkg-config >/dev/null 2>&1; then
for dep in sdl3 wayland-client xcb freetype2 vulkan; do
if pkg-config --exists "$dep"; then
printf 'Source dependency: %s found\n' "$dep"
else
printf 'Source dependency: %s MISSING\n' "$dep" >&2
errors=1
fi
done
fi
fi fi
if [ "$errors" -ne 0 ]; then if [ "$errors" -ne 0 ]; then
printf '%s\n' 'Preflight failed. No changes were made.' >&2 printf '%s\n' 'Preflight failed. No changes were made.' >&2
exit 1 exit 1
fi fi
printf '%s\n' 'Preflight passed for the proposed package format. No installer or release payload exists yet; nothing was installed.' printf 'Preflight passed for %s prerequisites; no changes were made.\n' "$mode"
+442 -49
View File
@@ -1,5 +1,9 @@
"""FrameYap installer implementation. Embedded verbatim in install.sh for piped installs.""" """FrameYap installer implementation. Embedded verbatim in install.sh for piped installs."""
import argparse import argparse
import contextlib
from datetime import datetime
import io
import importlib.util
import fcntl import fcntl
import hashlib import hashlib
import json import json
@@ -7,24 +11,28 @@ import os
from pathlib import Path from pathlib import Path
import platform import platform
import re import re
import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tarfile import tarfile
import tempfile import tempfile
from urllib.parse import quote from urllib.parse import quote
import urllib.request
KEY = "local.frameyap.overlay" KEY = "local.frameyap.overlay"
MARKER = "# FrameYap managed launcher v1\n" MARKER = "# FrameYap managed launcher v1\n"
ARCHIVE_LIMIT = 12 * 1024**3 ARCHIVE_LIMIT = 12 * 1024**3
MEMBER_LIMIT = 50000 MEMBER_LIMIT = 50000
VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z") VERSION_RE = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]{11}\Z")
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z") DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = { CONFIG_DEFAULTS = {
"font": "", "font": "",
"input_priority": "normal", "input_priority": "normal",
"advanced_debug": False, "advanced_debug": False,
"auto_insert": False, "auto_insert": False,
"close_mic_when_idle": False,
"backend": "redux",
"lock_layout": False, "lock_layout": False,
"clock_24h": False, "clock_24h": False,
"date_format": "mdy", "date_format": "mdy",
@@ -41,6 +49,7 @@ CONFIG_DEFAULTS = {
} }
COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z") 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") BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z")
BACKEND_RE = re.compile(r"[a-z][a-z0-9_-]{0,47}\Z", re.ASCII)
def fail(message): def fail(message):
@@ -92,6 +101,10 @@ def normalized_config(data):
fixed["advanced_debug"] = debug if type(debug) is bool else False fixed["advanced_debug"] = debug if type(debug) is bool else False
automatic = data.get("auto_insert", False) automatic = data.get("auto_insert", False)
fixed["auto_insert"] = automatic if type(automatic) is bool else False fixed["auto_insert"] = automatic if type(automatic) is bool else False
close_mic = data.get("close_mic_when_idle", False)
fixed["close_mic_when_idle"] = close_mic if type(close_mic) is bool else False
backend = data.get("backend", "redux")
fixed["backend"] = backend if isinstance(backend, str) and BACKEND_RE.fullmatch(backend) else "redux"
layout = data.get("lock_layout", False) layout = data.get("lock_layout", False)
fixed["lock_layout"] = layout if type(layout) is bool else False fixed["lock_layout"] = layout if type(layout) is bool else False
clock = data.get("clock_24h", False) clock = data.get("clock_24h", False)
@@ -193,8 +206,12 @@ def check_digest(value):
def check_version(value): def check_version(value):
if not VERSION_RE.fullmatch(value) or value in (".", ".."): if not VERSION_RE.fullmatch(value):
fail("invalid release version/tag") fail("version must be numeric MAJOR.MINOR.YYYYMMDDHHMM (no leading zeros, v prefix or git suffix)")
try:
datetime.strptime(value.rsplit(".", 1)[1], "%Y%m%d%H%M")
except ValueError:
fail("invalid UTC date/time in version")
return value return value
@@ -235,7 +252,7 @@ def verify_members(archive):
total += member.size total += member.size
if total > ARCHIVE_LIMIT: if total > ARCHIVE_LIMIT:
fail("archive uncompressed limit exceeded") fail("archive uncompressed limit exceeded")
if (parts[0] not in ("release.json", "bin", "lib", "assets", "python", "runtime", "model", "fonts", "licenses") if (parts[0] not in ("release.json", "bin", "lib", "assets", "python", "scripts", "runtime", "model", "fonts", "licenses")
or (parts[0] == "release.json" and (len(parts) != 1 or not member.isfile())) or (parts[0] == "release.json" and (len(parts) != 1 or not member.isfile()))
or (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())): or (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())):
fail(f"unexpected archive path: {name}") fail(f"unexpected archive path: {name}")
@@ -273,9 +290,11 @@ def validate_payload(root, version, without_model=False, installed=False):
fail("missing declared model") fail("missing declared model")
if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists(): if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists():
fail("external runtime payload must not include a runtime") fail("external runtime payload must not include a runtime")
for name in ("bin/frameyap", "runtime/bin/python3", "python/frameyap/worker.py", "assets/actions.json", "fonts/font.ttf"): for name in ("bin/frameyap", "bin/install.sh", "runtime/bin/python3", "python/frameyap/worker.py", "assets/actions.json", "fonts/font.ttf"):
if name == "runtime/bin/python3" and meta["runtime"] == "external-authorized-python": if name == "runtime/bin/python3" and meta["runtime"] == "external-authorized-python":
continue continue
if name == "bin/install.sh" and installed and not VERSION_RE.fullmatch(version):
continue # legacy installed release, before a managed UI child existed
if not (root / name).is_file(): if not (root / name).is_file():
fail(f"missing payload file: {name}") fail(f"missing payload file: {name}")
for name in ("lib", "fonts"): for name in ("lib", "fonts"):
@@ -437,6 +456,194 @@ def installed_choice(path):
return choice["without_model"] return choice["without_model"]
class ManifestMismatch(ValueError):
"""The installed manifest does not match the UI's selected metadata."""
def manifest_digest(path):
"""Hash the exact installed ID.json bytes, never a symlink or directory."""
import stat
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > 65536:
fail("installed backend manifest is oversized or unsafe")
raw = stream.read(65537)
if len(raw) > 65536:
fail("installed backend manifest is oversized or unsafe")
return hashlib.sha256(raw).hexdigest()
def installed_backend(root, backend_id):
current = selected(root, "current")
if not current:
fail("no installed release; install a verified archive before provisioning models")
version = root / current
installed_choice(version)
check_inventory(version)
module_path = version / "python/frameyap/model_files.py"
if module_path.is_symlink() or not module_path.is_file():
fail("installed release has no shared backend manifest verifier")
if module_path.stat().st_size > 262144:
fail("installed backend verifier exceeds size limit")
spec = importlib.util.spec_from_file_location("frameyap_installed_model_files", module_path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
# Executing compiled bytes avoids __pycache__ inside the inventory-protected
# installed release; a machine-readable installer must not mutate it.
exec(compile(module_path.read_bytes(), str(module_path), "exec"), module.__dict__)
manifests = module.load_backends(version / "assets/backends")
if backend_id not in manifests:
fail(f"unknown backend: {backend_id}; available: {', '.join(sorted(manifests))}")
# load_backends enforces filename == manifest id. Hash only that selected file,
# not all manifests or their parsed/reformatted representation.
digest = manifest_digest(version / "assets/backends" / (backend_id + ".json"))
return module, manifests[backend_id], digest
def check_expected_manifest(args, installed_sha):
if args.expected_manifest_sha256 and args.expected_manifest_sha256.lower() != installed_sha:
raise ManifestMismatch(f"installed {args.backend}.json manifest SHA-256 mismatch: "
f"expected {args.expected_manifest_sha256.lower()}, got {installed_sha}")
def model_target(args, root):
if args.model_dir:
if not args.model_dir.is_absolute():
fail("--model-dir must be an absolute local path")
return args.model_dir
return root / "models" / args.backend
def install_model(args, root):
module, backend, manifest_sha = installed_backend(root, args.backend)
check_expected_manifest(args, manifest_sha) # under .model.lock, before model mkdir/network
dest = model_target(args, root)
# No credentials, redirects to HTTP, symlinks or overwrite of mismatched files.
for ancestor in (dest, *dest.parents):
if ancestor.is_symlink():
fail(f"refusing symlink in model directory path: {ancestor}")
owned_dir(dest)
if not backend.source.startswith("https://"):
fail("model source must be HTTPS")
for item in backend.files:
target = dest / item.path
owned_dir(target.parent)
reason, _ = module.check_file(dest, item)
if reason is None:
if args.json:
print(json.dumps({"ok": True, "event": "model_file", "file": item.path, "state": "verified"}), flush=True)
continue
if reason not in ("missing_files",):
fail(f"refusing mismatched or unsafe model file: {target} ({reason})")
url = f"{backend.source}/resolve/{quote(backend.revision, safe='')}/{quote(item.path, safe='/')}"
if args.json:
print(json.dumps({"ok": True, "event": "model_file", "file": item.path, "state": "downloading", "bytes": item.size}), flush=True)
fd, temp = tempfile.mkstemp(prefix=".download-", dir=target.parent)
try:
with os.fdopen(fd, "wb") as output:
# The shared verifier is used for both pre-existing and downloaded files.
request = urllib.request.Request(url, headers={"User-Agent": "frameyap-installer"})
with urllib.request.urlopen(request, timeout=60) as response:
if response.geturl().split(":", 1)[0] != "https":
fail("model URL redirected away from HTTPS")
total = 0
while chunk := response.read(1024 * 1024):
total += len(chunk)
if total > item.size:
fail(f"model download exceeded pinned size: {item.path}")
output.write(chunk)
output.flush()
os.fsync(output.fileno())
temporary = type(item)(Path(temp).name, item.size, item.sha256)
if module.check_file(target.parent, temporary)[0] is not None:
fail(f"pinned SHA-256/size mismatch: {item.path}")
if target.exists() or target.is_symlink():
fail(f"model destination changed during download: {target}")
os.replace(temp, target)
if args.json:
print(json.dumps({"ok": True, "event": "model_file", "file": item.path, "state": "verified"}), flush=True)
finally:
Path(temp).unlink(missing_ok=True)
state = module.check_model(backend, dest)
if state["state"] != "installed_verified":
fail(f"model did not verify: {state['reason']}")
if not args.json:
print(f"Backend {backend.id} model verified at {dest}; license {backend.license_id}. Configure runtime/model paths explicitly for launch.")
def source_preflight(args):
"""Read-only checks before the installer creates its install root."""
source = Path(args.source).expanduser().resolve(strict=True)
if not source.is_dir() or not (source / "CMakeLists.txt").is_file():
fail("--source must name a local FrameYap source directory")
for script in ("scripts/install-preflight.sh", "scripts/stage-native.py", "scripts/package-release.py"):
if not (source / script).is_file() or (source / script).is_symlink():
fail(f"source missing regular packaging script: {script}")
sdk = Path(args.openvr_root).expanduser().resolve(strict=True)
if not (sdk / "headers/openvr.h").is_file():
fail("--openvr-root must contain headers/openvr.h")
inputs = {}
for name in ("openvr_library", "openvr_license", "sdl_library", "sdl_license"):
path = Path(getattr(args, name)).expanduser().resolve(strict=True)
if not path.is_file():
fail(f"--{name.replace('_', '-')} must be a local file")
inputs[name] = str(path)
for tool in ("cmake", "c++", "pkg-config", "wayland-scanner"):
if not shutil.which(tool):
fail(f"source prerequisite missing: {tool}; no download/package install attempted")
preflight = subprocess.run(["sh", str(source / "scripts/install-preflight.sh"), "--source"],
capture_output=True, text=True, check=False)
if preflight.returncode:
fail(f"source preflight failed (exit {preflight.returncode}): {preflight.stderr[-2000:]}")
for dep in ("sdl3", "wayland-client", "xcb", "freetype2", "vulkan"):
if subprocess.run(["pkg-config", "--exists", dep], check=False).returncode:
fail(f"source dependency missing: {dep}; provide local native dependencies")
return source, sdk, inputs
def source_model_revision(source):
"""Use the explicit source tree's pinned manifest, not a frozen installer hash."""
manifest = source / "assets/backends/redux.json"
if manifest.is_symlink() or not manifest.is_file() or manifest.stat().st_size > 65536:
fail("source missing safe pinned Redux backend manifest")
data = json.loads(manifest.read_text())
if not isinstance(data, dict) or data.get("id") != "redux" or not isinstance(data.get("model"), dict):
fail("invalid source Redux backend manifest")
revision = data["model"].get("revision")
if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
fail("invalid pinned Redux model revision in source manifest")
return revision
def source_archive(args, work):
"""Build only explicitly supplied local sources/dependencies in private work dir."""
source, sdk, inputs = source_preflight(args)
revision = source_model_revision(source)
build = work / "build"
stage = work / "stage"
output = work / "out"
output.mkdir(mode=0o700)
commands = [
["cmake", "-S", str(source), "-B", str(build), "-DFRAMEYAP_NATIVE=ON",
f"-DOPENVR_ROOT={sdk}", f"-DFRAMEYAP_VERSION={args.version}"],
["cmake", "--build", str(build)],
[sys.executable, str(source / "scripts/stage-native.py"), "--build", str(build),
"--destination", str(stage), *[x for name in inputs for x in ("--" + name.replace("_", "-"), inputs[name])]],
[sys.executable, str(source / "scripts/package-release.py"), "--stage", str(stage),
"--output", str(output), "--arch", "linux-aarch64", "--version", args.version,
"--model-revision", revision, "--external-runtime"],
]
for cmd in commands:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode:
fail(f"source command failed ({cmd[0]}, exit {result.returncode}): {result.stderr[-2000:]}")
archive = output / release_name(args.version)
if not archive.is_file():
fail("source packaging did not produce the expected release archive")
return archive, digest_file(archive)
def do_install(args, root, launcher): def do_install(args, root, launcher):
version = check_version(args.version) version = check_version(args.version)
versions = root / "versions" versions = root / "versions"
@@ -446,17 +653,20 @@ def do_install(args, root, launcher):
# Refuse foreign wrappers/launcher directories before installing a new version. # Refuse foreign wrappers/launcher directories before installing a new version.
owned_dir(launcher.parent) owned_dir(launcher.parent)
check_wrappers(root, launcher) check_wrappers(root, launcher)
if args.archive:
archive = Path(args.archive).expanduser().resolve(strict=True)
expected = check_digest(args.sha256)
do_download = False
else:
do_download = True
archive = None
with tempfile.TemporaryDirectory(prefix=".download-", dir=root) as td: with tempfile.TemporaryDirectory(prefix=".download-", dir=root) as td:
if args.source:
archive, expected = source_archive(args, Path(td))
do_download = False
elif args.archive:
archive = Path(args.archive).expanduser().resolve(strict=True)
expected = check_digest(args.sha256)
do_download = False
else:
do_download = True
archive = None
if do_download: if do_download:
name = release_name(version) name = release_name(version)
url = f"https://github.com/{args.repo}/releases/download/{quote(version)}/{name}" url = f"https://github.com/{args.repo}/releases/download/{quote('v' + version)}/{name}"
archive = Path(td) / name archive = Path(td) / name
sidecar = Path(td) / (name + ".sha256") sidecar = Path(td) / (name + ".sha256")
download(url + ".sha256", sidecar) download(url + ".sha256", sidecar)
@@ -552,66 +762,249 @@ def uninstall(root, launcher):
print("FrameYap removed; config and saved models preserved. OpenVR unregister acknowledgement was required.") print("FrameYap removed; config and saved models preserved. OpenVR unregister acknowledgement was required.")
def main(argv=None): class UsageError(ValueError):
parser = argparse.ArgumentParser(prog="install.sh", description="User-local FrameYap release installer (no SteamVR actions)") pass
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--archive", help="local release archive (requires --sha256 and --version)")
mode.add_argument("--rollback", action="store_true") class InstallerParser(argparse.ArgumentParser):
mode.add_argument("--uninstall", action="store_true") def error(self, message):
raise UsageError(message)
def resolve_args(argv):
parser = InstallerParser(prog="install.sh", description="User-local FrameYap installer; never launches the overlay")
action = parser.add_mutually_exclusive_group()
action.add_argument("--archive", help="local release archive (requires --sha256 and --version)")
action.add_argument("--rollback", action="store_true")
action.add_argument("--uninstall", action="store_true")
action.add_argument("--install-model", action="store_true", help="explicit model provisioning for installed backend")
parser.add_argument("--mode", choices=("binary", "source"), default="binary")
parser.add_argument("--source", help="explicit local FrameYap source tree (source mode only)")
parser.add_argument("--openvr-root", help="local OpenVR SDK root (source mode)")
for name in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
parser.add_argument("--" + name, help="explicit local source packaging input")
parser.add_argument("--sha256") parser.add_argument("--sha256")
parser.add_argument("--version") parser.add_argument("--version", help="numeric MAJOR.MINOR.YYYYMMDDHHMM; GitHub tag is vVERSION")
parser.add_argument("--repo", default="baketnk/frame-yap") parser.add_argument("--repo", default="baketnk/frame-yap")
parser.add_argument("--backend", default="redux")
parser.add_argument("--model-dir", type=Path)
parser.add_argument("--expected-manifest-sha256", help="SHA-256 of selected installed backend ID.json bytes")
parser.add_argument("--yes", action="store_true", help="explicit consent to network/model provisioning")
parser.add_argument("--autolaunch", dest="autolaunch", action="store_true", default=None)
parser.add_argument("--no-autolaunch", dest="autolaunch", action="store_false")
parser.add_argument("--without-model", action="store_true") parser.add_argument("--without-model", action="store_true")
parser.add_argument("--print-plan", action="store_true", help="read-only plan; no lock, download or install")
parser.add_argument("--json", action="store_true", help="one JSON result or error on stdout")
parser.add_argument("--unregistered", action="store_true", help="acknowledge explicit OpenVR removal before uninstall") parser.add_argument("--unregistered", action="store_true", help="acknowledge explicit OpenVR removal before uninstall")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.repo != "baketnk/frame-yap": if args.repo != "baketnk/frame-yap":
parser.error("only the pinned baketnk/frame-yap release repository is supported") parser.error("only the pinned baketnk/frame-yap release repository is supported")
source_inputs = ("openvr_root", "openvr_library", "openvr_license", "sdl_library", "sdl_license")
if args.mode == "source":
if not args.source or any(not getattr(args, name) for name in source_inputs):
parser.error("--mode source requires --source and explicit --openvr-root, --openvr-library, --openvr-license, --sdl-library, --sdl-license")
if args.archive or args.sha256 or args.rollback or args.uninstall or args.install_model:
parser.error("source mode cannot combine with binary archive or lifecycle operations")
elif args.source or any(getattr(args, name) for name in source_inputs):
parser.error("source inputs require --mode source")
if args.archive and (not args.sha256 or not args.version): if args.archive and (not args.sha256 or not args.version):
parser.error("--archive requires --sha256 and --version") parser.error("--archive requires --sha256 and --version")
if args.archive and not DIGEST_RE.fullmatch(args.sha256):
parser.error("--sha256 must be a 64-character hex SHA-256")
if not args.archive and args.sha256: if not args.archive and args.sha256:
parser.error("--sha256 only applies to --archive") parser.error("--sha256 only applies to --archive")
if not (args.rollback or args.uninstall or args.archive) and not args.version: if not (args.rollback or args.uninstall or args.install_model) and not args.version:
parser.error("--version TAG is required; no moving/latest release") parser.error("--version VERSION is required; no moving/latest release")
if args.uninstall and not args.unregistered: if args.uninstall != args.unregistered:
parser.error("uninstall requires --unregistered after explicit OpenVR unregister") parser.error("--uninstall requires --unregistered after explicit OpenVR unregister")
if args.unregistered and not args.uninstall: if args.model_dir and not args.install_model:
parser.error("--unregistered only applies to --uninstall") parser.error("--model-dir applies only to --install-model")
if args.expected_manifest_sha256 is not None:
if not args.install_model:
parser.error("--expected-manifest-sha256 applies only to --install-model")
if not DIGEST_RE.fullmatch(args.expected_manifest_sha256):
parser.error("--expected-manifest-sha256 must be a 64-character hex SHA-256")
if args.model_dir and not args.model_dir.is_absolute():
parser.error("--model-dir must be an absolute local path")
if args.backend != "redux" and not args.install_model:
parser.error("--backend ID currently applies only to --install-model; select the active backend in FrameYap settings")
if args.install_model and (args.without_model or args.version or args.archive or args.autolaunch is not None):
parser.error("--install-model uses the installed version; cannot combine with archive/version/without-model/autolaunch")
if (args.rollback or args.uninstall) and (args.version or args.without_model or args.autolaunch is not None or args.backend != "redux"):
parser.error("lifecycle actions cannot combine with installation/model flags")
if args.version: if args.version:
check_version(args.version) try:
check_version(args.version)
except ValueError as error:
parser.error(str(error))
return args
def plan(args):
operation = ("uninstall" if args.uninstall else "rollback" if args.rollback else
"install-model" if args.install_model else "install")
return {"operation": operation, "mode": args.mode, "version": args.version,
"tag": "v" + args.version if args.version else None,
"archive": str(args.archive) if args.archive else None,
"source": str(args.source) if args.source else None,
"backend": args.backend, "model_dir": str(args.model_dir) if args.model_dir else None,
"expected_manifest_sha256": (args.expected_manifest_sha256.lower() if args.expected_manifest_sha256 else None),
"without_model": args.without_model, "autolaunch": args.autolaunch,
"network": bool(args.install_model or (operation == "install" and not args.archive and not args.source)),
"registration": ("explicit --register --autostart" if args.autolaunch is True else
"explicit --register (autostart off)" if args.autolaunch is False else "none")}
def main(argv=None):
args = resolve_args(argv)
if args.print_plan:
result = plan(args)
if args.install_model:
data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute()
root = data / "frameyap"
_, backend, manifest_sha = installed_backend(root, args.backend)
check_expected_manifest(args, manifest_sha)
result.update(model_dir=str(model_target(args, root)), model=backend.description(),
installed_manifest_sha256=manifest_sha)
if args.json:
print(json.dumps({"ok": True, "event": "plan", **result}, sort_keys=True))
else:
print(json.dumps(result, indent=2, sort_keys=True))
return
check_host() check_host()
if args.mode == "source":
source_preflight(args)
if (not args.archive and not args.source and not args.rollback and not args.uninstall) and not args.yes:
fail("network/model install requires explicit --yes (no stdin prompts); use --print-plan first")
data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute() data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute()
root = data / "frameyap" root = data / "frameyap"
launcher = Path.home() / ".local/bin/frameyap" launcher = Path.home() / ".local/bin/frameyap"
owned_dir(root) owned_dir(root)
lock = root / ".lock" # UI child model provisioning must work while the overlay holds .lock.
# Models live outside versions and never replace a running executable.
lock = root / (".model.lock" if args.install_model else ".lock")
if lock.is_symlink(): if lock.is_symlink():
fail("refusing symlink lock") fail("refusing symlink lock")
with lock.open("a+b") as fd: with lock.open("a+b") as fd:
try: try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
fail("FrameYap installer or application is running (.lock held); try again later") fail(f"FrameYap installer or application is running ({lock.name} held); try again later")
if args.uninstall: other = root / ".model.lock"
uninstall(root, launcher) if not args.install_model and other.is_symlink():
elif args.rollback: fail("refusing symlink model lock")
owned_dir(root / "versions") with (contextlib.nullcontext() if args.install_model else other.open("a+b")) as model_fd:
current, previous = selected(root, "current"), selected(root, "previous") if model_fd is not None:
if not current or not previous: try:
fail("no previous installation to roll back to") fcntl.flock(model_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
check_wrappers(root, launcher) except BlockingIOError:
choice = installed_choice(root / previous) fail("model provisioning is running (.model.lock held); retry later")
check_inventory(root / previous) if args.uninstall:
validate_payload(root / previous, Path(previous).name, choice, installed=True) uninstall(root, launcher)
select(root, "current", previous) elif args.rollback:
select(root, "previous", current) owned_dir(root / "versions")
print(f"Rolled back to {previous}") current, previous = selected(root, "current"), selected(root, "previous")
if not current or not previous:
fail("no previous installation to roll back to")
check_wrappers(root, launcher)
choice = installed_choice(root / previous)
check_inventory(root / previous)
validate_payload(root / previous, Path(previous).name, choice, installed=True)
select(root, "current", previous)
select(root, "previous", current)
print(f"Rolled back to {previous}")
elif args.install_model:
install_model(args, root)
else:
do_install(args, root, launcher)
# The native registration helper takes this same install lock. Never run it
# until the installer has released its own lock; never initialize OpenVR by default.
if args.autolaunch is not None:
command = [str(launcher), "--register", str(root / "frameyap.vrmanifest")]
if args.autolaunch:
command.append("--autostart")
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode:
fail(f"files installed, but opt-in OpenVR autolaunch registration failed (exit {result.returncode}): {result.stderr[-1000:]}")
print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request")
def interactive_options():
"""Only an empty, actual terminal invocation offers a guided local choice."""
print("FrameYap installer: choose binary (verified archive) or source (local build).")
mode = input("Mode [binary/source]: ").strip().lower()
if mode not in ("binary", "source"):
raise UsageError("choose binary or source; no installation started")
version = input("Numeric release version (MAJOR.MINOR.YYYYMMDDHHMM): ").strip()
check_version(version)
chosen = ["--mode", mode, "--version", version]
if mode == "binary":
archive = input("Local archive path (leave empty for pinned GitHub release): ").strip()
if archive:
chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()]
else: else:
do_install(args, root, launcher) if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes":
raise UsageError("download not approved; no installation started")
chosen.append("--yes")
else:
for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"):
chosen += ["--" + flag, input(flag + " local path: ").strip()]
if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes":
chosen.append("--without-model")
if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes":
chosen.append("--autolaunch")
print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen))
return chosen
def cli(argv=None):
argv = sys.argv[1:] if argv is None else argv
structured = "--json" in argv
if not argv and sys.stdout.isatty():
# With `sh install.sh`, fd 0 is the embedded Python code, not the
# invoking terminal. fd 3 retains original stdin (including a pipe).
attended = sys.stdin
if not attended.isatty():
try:
if os.isatty(3):
attended = os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
if attended.isatty():
try:
original_stdin = sys.stdin
try:
sys.stdin = attended
argv = interactive_options()
finally:
sys.stdin = original_stdin
except (ValueError, EOFError) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr)
return 2
try:
if structured and "--print-plan" in argv:
main(argv) # already emits one JSON plan
elif structured and "--install-model" in argv:
main(argv) # streaming per-file JSON events for a UI child
print(json.dumps({"ok": True, "event": "complete"}))
elif structured:
capture = io.StringIO()
with contextlib.redirect_stdout(capture):
main(argv)
print(json.dumps({"ok": True, "event": "complete", "messages": capture.getvalue().splitlines()}))
else:
main(argv)
return 0
except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError, ImportError) as exc:
code = 2 if isinstance(exc, UsageError) else 1
if structured:
print(json.dumps({"ok": False, "code": ("usage" if code == 2 else
"manifest_mismatch" if isinstance(exc, ManifestMismatch) else "operation_failed"),
"message": str(exc), "exit_code": code}))
else:
print(f"frameyap installer: {exc}", file=sys.stderr)
return code
if __name__ == "__main__": if __name__ == "__main__":
try: sys.exit(cli())
main()
except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr)
sys.exit(1)
+10 -5
View File
@@ -4,6 +4,7 @@
This tool does not build/download a runtime, model, native libraries, or licenses. This tool does not build/download a runtime, model, native libraries, or licenses.
""" """
import argparse import argparse
from datetime import datetime
import hashlib import hashlib
import io import io
import json import json
@@ -15,8 +16,8 @@ import tarfile
import tempfile import tempfile
ARCH = "linux-aarch64" ARCH = "linux-aarch64"
ALLOWED = {"bin", "lib", "assets", "python", "runtime", "model", "fonts", "licenses"} ALLOWED = {"bin", "lib", "assets", "python", "scripts", "runtime", "model", "fonts", "licenses"}
REQUIRED = ("bin/frameyap", "runtime/bin/python3", "python/frameyap/worker.py", REQUIRED = ("bin/frameyap", "bin/install.sh", "runtime/bin/python3", "python/frameyap/worker.py",
"assets/actions.json", "fonts/font.ttf", "licenses/THIRD_PARTY_NOTICES.txt") "assets/actions.json", "fonts/font.ttf", "licenses/THIRD_PARTY_NOTICES.txt")
@@ -29,8 +30,12 @@ def main(argv=None):
p.add_argument("--model-revision", required=True, help="exact vetted model revision identifier") p.add_argument("--model-revision", required=True, help="exact vetted model revision identifier")
p.add_argument("--external-runtime", action="store_true", help="omit ASR runtime; user must supply an independently authorized Python environment") p.add_argument("--external-runtime", action="store_true", help="omit ASR runtime; user must supply an independently authorized Python environment")
args = p.parse_args(argv) args = p.parse_args(argv)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}", args.version) or args.version in (".", ".."): if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]{11}", args.version):
p.error("invalid version") p.error("--version must be numeric MAJOR.MINOR.YYYYMMDDHHMM (no leading zeros, v prefix or git suffix)")
try:
datetime.strptime(args.version.rsplit(".", 1)[1], "%Y%m%d%H%M")
except ValueError:
p.error("--version contains an invalid UTC date/time")
if not args.model_revision.strip(): if not args.model_revision.strip():
p.error("model revision must be nonempty") p.error("model revision must be nonempty")
stage = args.stage.resolve(strict=True) stage = args.stage.resolve(strict=True)
@@ -47,7 +52,7 @@ def main(argv=None):
p.error(f"missing file: {name}") p.error(f"missing file: {name}")
if args.external_runtime and (stage / "runtime").exists(): if args.external_runtime and (stage / "runtime").exists():
p.error("external-runtime package must not contain a runtime directory") p.error("external-runtime package must not contain a runtime directory")
for name in (("bin/frameyap",) if args.external_runtime else ("bin/frameyap", "runtime/bin/python3")): for name in (("bin/frameyap", "bin/install.sh") if args.external_runtime else ("bin/frameyap", "bin/install.sh", "runtime/bin/python3")):
if not os.access(stage / name, os.X_OK): if not os.access(stage / name, os.X_OK):
p.error(f"not executable: {name}") p.error(f"not executable: {name}")
for name in ("lib", "fonts", "licenses"): for name in ("lib", "fonts", "licenses"):
+3 -54
View File
@@ -1,57 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Stage a native-only POC from an explicit native build and licensed files. """Compatibility entry point for scripts/stage-native.py."""
No downloads, compiler invocation, ASR runtime, registration or launch.
System Vulkan loader/driver, Wayland/FreeType/libstdc++/glibc remain platform prerequisites.
"""
import argparse
from pathlib import Path from pathlib import Path
import shutil import runpy
import subprocess
runpy.run_path(str(Path(__file__).with_name('stage-native.py')), run_name='__main__')
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--build", type=Path, required=True)
p.add_argument("--destination", type=Path, required=True, help="new staging directory")
for name in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
p.add_argument("--" + name, type=Path, required=True)
p.add_argument("--font", type=Path, help="override bundled Inconsolata (requires --font-license)")
p.add_argument("--font-license", type=Path)
args = p.parse_args()
root = Path(__file__).resolve().parents[1]
if bool(args.font) != bool(args.font_license):
p.error("a font override requires both --font and --font-license")
args.font = args.font or root / "assets/fonts/Inconsolata-Regular.ttf"
args.font_license = args.font_license or root / "assets/fonts/OFL-Inconsolata.txt"
dest = args.destination.absolute()
if dest.exists() or dest.is_symlink():
p.error("destination already exists; use a new staging directory")
for name in ("openvr_library", "openvr_license", "sdl_library", "sdl_license", "font", "font_license"):
if not getattr(args, name).is_file():
p.error(f"missing explicit input {name}")
cache = (args.build / "CMakeCache.txt").read_text()
if "FRAMEYAP_NATIVE:BOOL=ON" not in cache:
p.error("build must explicitly enable FRAMEYAP_NATIVE")
dest.mkdir(mode=0o700, parents=True)
subprocess.run(["cmake", "--install", str(args.build.absolute()), "--prefix", str(dest)], check=True)
for directory in ("lib", "fonts", "licenses"):
(dest / directory).mkdir()
shutil.copyfile(args.openvr_library, dest / "lib/libopenvr_api.so", follow_symlinks=True)
shutil.copyfile(args.sdl_library, dest / "lib/libSDL3.so.0", follow_symlinks=True)
shutil.copyfile(args.font, dest / "fonts/font.ttf")
notices = ["FrameYap native-only POC. No ASR runtime or model is included.\n",
"Original FrameYap code: MIT. System Vulkan/Wayland/FreeType/libstdc++/glibc are not bundled.\n",
"Bundled libraries: Valve OpenVR and unmodified SDL3; font license included below.\n",
"This package does not include Kestrel or provide a functioning ASR environment.\n"]
for label, file in (("FrameYap", root / "LICENSE"), ("OpenVR", args.openvr_license),
("SDL3", args.sdl_license), ("Font", args.font_license)):
notices.extend([f"\n--- {label} ---\n", file.read_text()])
notices.extend(["\n--- Gamescope protocol: embedded copyright/license ---\n",
(root / "protocol/gamescope-input-method.xml").read_text()])
(dest / "licenses/THIRD_PARTY_NOTICES.txt").write_text("\n".join(notices))
print(f"Native-only stage: {dest}. Package with --external-runtime; do not claim bundled ASR.")
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Stage a native-only release from an explicit native build and licensed files.
No downloads, compiler invocation, ASR runtime, registration or launch.
System Vulkan loader/driver, Wayland/FreeType/libstdc++/glibc remain platform prerequisites.
"""
import argparse
from pathlib import Path
import shutil
import subprocess
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--build", type=Path, required=True)
p.add_argument("--destination", type=Path, required=True, help="new staging directory")
for name in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
p.add_argument("--" + name, type=Path, required=True)
p.add_argument("--font", type=Path, help="override bundled Inconsolata (requires --font-license)")
p.add_argument("--font-license", type=Path)
args = p.parse_args()
root = Path(__file__).resolve().parents[1]
if bool(args.font) != bool(args.font_license):
p.error("a font override requires both --font and --font-license")
args.font = args.font or root / "assets/fonts/Inconsolata-Regular.ttf"
args.font_license = args.font_license or root / "assets/fonts/OFL-Inconsolata.txt"
dest = args.destination.absolute()
if dest.exists() or dest.is_symlink():
p.error("destination already exists; use a new staging directory")
for name in ("openvr_library", "openvr_license", "sdl_library", "sdl_license", "font", "font_license"):
if not getattr(args, name).is_file():
p.error(f"missing explicit input {name}")
cache = (args.build / "CMakeCache.txt").read_text()
if "FRAMEYAP_NATIVE:BOOL=ON" not in cache:
p.error("build must explicitly enable FRAMEYAP_NATIVE")
dest.mkdir(mode=0o700, parents=True)
subprocess.run(["cmake", "--install", str(args.build.absolute()), "--prefix", str(dest)], check=True)
# The panel can launch this installer as a child after an explicit model
# consent click. It is self-contained (heredoc payload), not a network stub.
shutil.copyfile(root / "install.sh", dest / "bin/install.sh")
(dest / "bin/install.sh").chmod(0o755)
for directory in ("lib", "fonts", "licenses"):
(dest / directory).mkdir()
shutil.copyfile(args.openvr_library, dest / "lib/libopenvr_api.so", follow_symlinks=True)
shutil.copyfile(args.sdl_library, dest / "lib/libSDL3.so.0", follow_symlinks=True)
shutil.copyfile(args.font, dest / "fonts/font.ttf")
notices = ["FrameYap native-only release. No ASR runtime or model is included.\n",
"Original FrameYap code: MIT. System Vulkan/Wayland/FreeType/libstdc++/glibc are not bundled.\n",
"This software is based in part on the work of the FreeType Team. "
"FreeType is a system dynamic library, not bundled in this native-only stage.\n",
"Bundled libraries: Valve OpenVR and unmodified SDL3; font license included below.\n",
"This package does not include Kestrel or provide a functioning ASR environment.\n"]
for label, file in (("FrameYap", root / "LICENSE"), ("OpenVR", args.openvr_license),
("SDL3", args.sdl_license), ("Font", args.font_license)):
notices.extend([f"\n--- {label} ---\n", file.read_text()])
notices.extend(["\n--- Gamescope protocol: embedded copyright/license ---\n",
(root / "protocol/gamescope-input-method.xml").read_text()])
(dest / "licenses/THIRD_PARTY_NOTICES.txt").write_text("\n".join(notices))
print(f"Native-only stage: {dest}. Package with --external-runtime; do not claim bundled ASR.")
if __name__ == "__main__":
main()
+15 -3
View File
@@ -33,8 +33,20 @@ check() {
check 0 'Git: missing (not required' /usr/bin/env check 0 'Git: missing (not required' /usr/bin/env
check 0 'uv: missing (not required' /usr/bin/env check 0 'uv: missing (not required' /usr/bin/env
check 0 'Preflight passed' /usr/bin/env check 0 'Preflight passed' /usr/bin/env
check 0 'older than 3.12' /usr/bin/env MOCK_PYTHON='Python 3.11.9' check 1 'older than 3.12' /usr/bin/env MOCK_PYTHON='Python 3.11.9'
for dep in cmake c++ pkg-config wayland-scanner; do
printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/$dep"
/bin/chmod +x "$tmp/bin/$dep"
done
output=$(PATH="$tmp/bin" /bin/sh "$script" --source 2>&1)
case "$output" in *'Source dependency: vulkan found'*) ;; *) echo "Source preflight missed Vulkan: $output" >&2; exit 1;; esac
printf '#!/bin/sh\n[ "$2" != vulkan ]\n' > "$tmp/bin/pkg-config"
/bin/chmod +x "$tmp/bin/pkg-config"
status=0
output=$(PATH="$tmp/bin" /bin/sh "$script" --source 2>&1) || status=$?
[ "$status" -eq 1 ] || { echo "Expected failing source preflight: $output" >&2; exit 1; }
case "$output" in *'Source dependency: vulkan MISSING'*) ;; *) echo "Missing Vulkan failure: $output" >&2; exit 1;; esac
check 1 'Linux AArch64 only' /usr/bin/env MOCK_ARCH=x86_64 check 1 'Linux AArch64 only' /usr/bin/env MOCK_ARCH=x86_64
check 1 'require glibc' /usr/bin/env MOCK_LIBC=musl check 1 'require glibc' /usr/bin/env MOCK_LIBC=musl
/bin/rm "$tmp/bin/curl" /bin/rm "$tmp/bin/python3"
check 1 'curl MISSING' /usr/bin/env check 1 'python3 MISSING' /usr/bin/env
+531 -99
View File
@@ -13,6 +13,10 @@ import tarfile
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
import shutil
import pty
import select
from types import SimpleNamespace
REPO = Path(__file__).resolve().parents[1] REPO = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("install_payload", REPO / "scripts/install_payload.py") SPEC = importlib.util.spec_from_file_location("install_payload", REPO / "scripts/install_payload.py")
@@ -39,6 +43,10 @@ class InstallTests(unittest.TestCase):
path.write_bytes((name + " fixture\n").encode()) path.write_bytes((name + " fixture\n").encode())
for name in ("bin/frameyap", "runtime/bin/python3", "runtime/bin/helper", "lib/libtest.so"): for name in ("bin/frameyap", "runtime/bin/python3", "runtime/bin/helper", "lib/libtest.so"):
(self.stage / name).chmod(0o755) (self.stage / name).chmod(0o755)
shutil.copyfile(REPO / "install.sh", self.stage / "bin/install.sh")
(self.stage / "bin/install.sh").chmod(0o755)
(self.stage / "scripts").mkdir()
(self.stage / "scripts/backend-service.py").write_text("# offline fixture service\n")
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")}) "XDG_CONFIG_HOME": str(self.home / ".config")})
self.env.start() self.env.start()
@@ -64,10 +72,10 @@ class InstallTests(unittest.TestCase):
(REPO / "scripts/install_payload.py").read_text()) (REPO / "scripts/install_payload.py").read_text())
def test_install_idempotence_upgrade_rollback_uninstall(self): def test_install_idempotence_upgrade_rollback_uninstall(self):
a1, h1 = self.package("v1") a1, h1 = self.package("0.1.202609241530")
self.install("v1", a1, h1) self.install("0.1.202609241530", a1, h1)
root = self.data / "frameyap" root = self.data / "frameyap"
self.assertEqual(os.readlink(root / "current"), "versions/v1") self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530")
launcher = self.home / ".local/bin/frameyap" launcher = self.home / ".local/bin/frameyap"
self.assertIn("--run", launcher.read_text()) self.assertIn("--run", launcher.read_text())
self.assertNotIn("--font", launcher.read_text()) # run/check modes honor config font self.assertNotIn("--font", launcher.read_text()) # run/check modes honor config font
@@ -75,11 +83,17 @@ class InstallTests(unittest.TestCase):
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS) self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS)
self.assertIs(json.loads(config.read_text())["advanced_debug"], False) self.assertIs(json.loads(config.read_text())["advanced_debug"], False)
self.assertIs(json.loads(config.read_text())["auto_insert"], False) self.assertIs(json.loads(config.read_text())["auto_insert"], False)
self.assertIs(json.loads(config.read_text())["close_mic_when_idle"], False)
self.assertEqual(json.loads(config.read_text())["backend"], "redux")
self.assertIs(json.loads(config.read_text())["lock_layout"], False) self.assertIs(json.loads(config.read_text())["lock_layout"], False)
self.assertEqual(list(config.parent.glob("config.json.backup-*")), []) self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text()) 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/0.1.202609241530/runtime/bin/helper", os.X_OK))
self.assertTrue(os.access(root / "versions/v1/lib/libtest.so", os.X_OK)) self.assertTrue(os.access(root / "versions/0.1.202609241530/lib/libtest.so", os.X_OK))
managed_installer = root / "current/bin/install.sh"
self.assertEqual(managed_installer.read_bytes(), (REPO / "install.sh").read_bytes())
self.assertTrue(os.access(managed_installer, os.X_OK))
self.assertTrue((root / "current/scripts/backend-service.py").is_file())
manifest = json.loads((root / "frameyap.vrmanifest").read_text()) manifest = json.loads((root / "frameyap.vrmanifest").read_text())
self.assertEqual(manifest["applications"][0]["app_key"], "local.frameyap.overlay") self.assertEqual(manifest["applications"][0]["app_key"], "local.frameyap.overlay")
self.assertEqual(manifest["applications"][0]["binary_path_linux"], str(launcher)) self.assertEqual(manifest["applications"][0]["binary_path_linux"], str(launcher))
@@ -89,38 +103,57 @@ class InstallTests(unittest.TestCase):
self.assertIn(f'Exec="{launcher}"', desktop.read_text()) self.assertIn(f'Exec="{launcher}"', desktop.read_text())
self.assertIn("Terminal=false", desktop.read_text()) self.assertIn("Terminal=false", desktop.read_text())
(root / "config-untouched").write_text("keep") (root / "config-untouched").write_text("keep")
self.install("v1", a1, h1) self.install("0.1.202609241530", a1, h1)
(self.stage / "bin/frameyap").write_text("next binary") (self.stage / "bin/frameyap").write_text("next binary")
a2, h2 = self.package("v2") a2, h2 = self.package("0.1.202609241531")
self.install("v2", a2, h2) self.install("0.1.202609241531", a2, h2)
self.assertEqual(os.readlink(root / "current"), "versions/v2") self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241531")
self.assertEqual(os.readlink(root / "previous"), "versions/v1") self.assertEqual(os.readlink(root / "previous"), "versions/0.1.202609241530")
(root / "frameyap.vrmanifest").write_text("foreign") (root / "frameyap.vrmanifest").write_text("foreign")
with self.assertRaisesRegex(ValueError, "foreign file"): with self.assertRaisesRegex(ValueError, "foreign file"):
installer.main(["--rollback"]) installer.main(["--rollback"])
self.assertEqual(os.readlink(root / "current"), "versions/v2") self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241531")
(root / "frameyap.vrmanifest").unlink() (root / "frameyap.vrmanifest").unlink()
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
installer.main(["--rollback"]) installer.main(["--rollback"])
self.assertEqual(os.readlink(root / "current"), "versions/v1") self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530")
self.assertEqual(os.readlink(root / "previous"), "versions/v2") self.assertEqual(os.readlink(root / "previous"), "versions/0.1.202609241531")
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): with self.assertRaises(installer.UsageError):
installer.main(["--uninstall"]) installer.main(["--uninstall"])
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
installer.main(["--uninstall", "--unregistered"]) installer.main(["--uninstall", "--unregistered"])
self.assertEqual((root / "config-untouched").read_text(), "keep") self.assertEqual((root / "config-untouched").read_text(), "keep")
self.assertTrue((root / "saved-models/v1/weights.bin").exists()) self.assertTrue((root / "saved-models/0.1.202609241530/weights.bin").exists())
self.assertTrue((root / "saved-models/v2/weights.bin").exists()) self.assertTrue((root / "saved-models/0.1.202609241531/weights.bin").exists())
self.assertFalse(launcher.exists()) self.assertFalse(launcher.exists())
self.assertFalse(desktop.exists()) self.assertFalse(desktop.exists())
def test_previous_legacy_version_remains_rollback_selectable(self):
first, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", first, digest)
root = self.data / "frameyap"
original = root / "versions/0.1.202609241530"
legacy = root / "versions/v0.1.0-poc-local"
original.rename(legacy)
meta = json.loads((legacy / "release.json").read_text())
meta["version"] = legacy.name # emulate older already-installed metadata
(legacy / "release.json").write_text(json.dumps(meta))
(root / "current").unlink()
(root / "current").symlink_to("versions/" + legacy.name)
second, digest2 = self.package("0.1.202609241531")
self.install("0.1.202609241531", second, digest2)
self.assertEqual(os.readlink(root / "previous"), "versions/" + legacy.name)
with contextlib.redirect_stdout(io.StringIO()):
installer.main(["--rollback"])
self.assertEqual(os.readlink(root / "current"), "versions/" + legacy.name)
def test_config_install_repairs_and_preserves_original(self): def test_config_install_repairs_and_preserves_original(self):
archive, digest = self.package("v1") archive, digest = self.package("0.1.202609241530")
config = self.home / ".config/frameyap/config.json" config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True) config.parent.mkdir(parents=True)
original = b'{"font":"/system/face.ttf","theme":{"ink":"#F1f2F3"},"buttons":{"ptt":"/user/hand/left/input/y"}}\n' original = b'{"font":"/system/face.ttf","theme":{"ink":"#F1f2F3"},"buttons":{"ptt":"/user/hand/left/input/y"}}\n'
config.write_bytes(original) config.write_bytes(original)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
fixed = json.loads(config.read_text()) fixed = json.loads(config.read_text())
self.assertEqual(fixed["font"], "/system/face.ttf") self.assertEqual(fixed["font"], "/system/face.ttf")
self.assertEqual(fixed["theme"]["ink"], "#F1f2F3") self.assertEqual(fixed["theme"]["ink"], "#F1f2F3")
@@ -147,6 +180,8 @@ class InstallTests(unittest.TestCase):
fixed["input_priority"] = "experimental" fixed["input_priority"] = "experimental"
fixed["advanced_debug"] = True fixed["advanced_debug"] = True
fixed["auto_insert"] = True fixed["auto_insert"] = True
fixed["close_mic_when_idle"] = True
fixed["backend"] = "custom_v2-1"
fixed["lock_layout"] = True fixed["lock_layout"] = True
fixed["clock_24h"] = True fixed["clock_24h"] = True
fixed["date_format"] = "iso" fixed["date_format"] = "iso"
@@ -155,10 +190,12 @@ class InstallTests(unittest.TestCase):
fixed["wrist"]["y"] = 0.2 fixed["wrist"]["y"] = 0.2
compact = json.dumps(fixed, separators=(",", ":")).encode() compact = json.dumps(fixed, separators=(",", ":")).encode()
config.write_bytes(compact) config.write_bytes(compact)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertEqual(config.read_bytes(), compact) self.assertEqual(config.read_bytes(), compact)
self.assertIs(json.loads(config.read_text())["advanced_debug"], True) self.assertIs(json.loads(config.read_text())["advanced_debug"], True)
self.assertIs(json.loads(config.read_text())["auto_insert"], True) self.assertIs(json.loads(config.read_text())["auto_insert"], True)
self.assertIs(json.loads(config.read_text())["close_mic_when_idle"], True)
self.assertEqual(json.loads(config.read_text())["backend"], "custom_v2-1")
self.assertIs(json.loads(config.read_text())["lock_layout"], True) self.assertIs(json.loads(config.read_text())["lock_layout"], True)
self.assertIs(json.loads(config.read_text())["clock_24h"], True) self.assertIs(json.loads(config.read_text())["clock_24h"], True)
self.assertEqual(json.loads(config.read_text())["date_format"], "iso") self.assertEqual(json.loads(config.read_text())["date_format"], "iso")
@@ -166,12 +203,12 @@ class InstallTests(unittest.TestCase):
fixed["advanced_debug"] = False fixed["advanced_debug"] = False
compact_off = json.dumps(fixed, separators=(",", ":")).encode() compact_off = json.dumps(fixed, separators=(",", ":")).encode()
config.write_bytes(compact_off) config.write_bytes(compact_off)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertEqual(config.read_bytes(), compact_off) self.assertEqual(config.read_bytes(), compact_off)
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1) self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
original = b'{"font":"/system/face.ttf","input_priority":"highest","wrist":{"x":0.04,"y":true,"width":100,"obsolete":4},"theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}' original = b'{"font":"/system/face.ttf","input_priority":"highest","wrist":{"x":0.04,"y":true,"width":100,"obsolete":4},"theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}'
config.write_bytes(original) config.write_bytes(original)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
fixed = json.loads(config.read_text()) fixed = json.loads(config.read_text())
self.assertEqual(fixed["font"], "/system/face.ttf") self.assertEqual(fixed["font"], "/system/face.ttf")
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"]) self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
@@ -189,118 +226,135 @@ class InstallTests(unittest.TestCase):
self.assertEqual(sorted(p.read_bytes() for p in config.parent.glob("config.json.backup-*")), sorted([backups[0].read_bytes(), original])) 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"' original = b'{"font":"one","font":"two"'
config.write_bytes(original) config.write_bytes(original)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS) 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-*")]) self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
config.write_text(json.dumps({"font": "/" + "x" * 3800})) config.write_text(json.dumps({"font": "/" + "x" * 3800}))
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertEqual(json.loads(config.read_text())["font"], "") 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-*"))) self.assertTrue(all(len(p.read_bytes()) > 0 for p in config.parent.glob("config.json.backup-*")))
config.write_bytes(b"x" * 65537) config.write_bytes(b"x" * 65537)
with self.assertRaisesRegex(ValueError, "too large"): with self.assertRaisesRegex(ValueError, "too large"):
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertEqual(config.stat().st_size, 65537) self.assertEqual(config.stat().st_size, 65537)
config.unlink() config.unlink()
config.symlink_to(self.stage / "model/weights.bin") config.symlink_to(self.stage / "model/weights.bin")
with self.assertRaisesRegex(ValueError, "foreign config path"): with self.assertRaisesRegex(ValueError, "foreign config path"):
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertTrue(config.is_symlink()) self.assertTrue(config.is_symlink())
def test_debug_boolean_repair_backs_up_invalid_values(self): def test_debug_boolean_repair_backs_up_invalid_values(self):
archive, digest = self.package("v1") archive, digest = self.package("0.1.202609241530")
config = self.home / ".config/frameyap/config.json" config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True) config.parent.mkdir(parents=True)
for invalid in ("true", 1, None, [], {}): for invalid in ("true", 1, None, [], {}):
original = json.dumps({"advanced_debug": invalid, "font": "/custom/font.ttf"}).encode() original = json.dumps({"advanced_debug": invalid, "font": "/custom/font.ttf"}).encode()
config.write_bytes(original) config.write_bytes(original)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertIs(json.loads(config.read_text())["advanced_debug"], False) self.assertIs(json.loads(config.read_text())["advanced_debug"], False)
self.assertEqual(json.loads(config.read_text())["font"], "/custom/font.ttf") self.assertEqual(json.loads(config.read_text())["font"], "/custom/font.ttf")
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
def test_auto_insert_boolean_repair_backs_up_invalid_values(self): def test_auto_insert_boolean_repair_backs_up_invalid_values(self):
archive, digest = self.package("v1") archive, digest = self.package("0.1.202609241530")
config = self.home / ".config/frameyap/config.json" config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True) config.parent.mkdir(parents=True)
for invalid in ("true", 1, None, [], {}): for invalid in ("true", 1, None, [], {}):
original = json.dumps({"auto_insert": invalid, "font": "/custom/font.ttf"}).encode() original = json.dumps({"auto_insert": invalid, "font": "/custom/font.ttf"}).encode()
config.write_bytes(original) config.write_bytes(original)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
self.assertIs(json.loads(config.read_text())["auto_insert"], False) self.assertIs(json.loads(config.read_text())["auto_insert"], False)
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
def test_lock_layout_boolean_repair_backs_up_invalid_values(self): def test_lock_layout_boolean_repair_backs_up_invalid_values(self):
archive, digest = self.package("v1") archive, digest = self.package("0.1.202609241530")
config = self.home / ".config/frameyap/config.json" config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True) config.parent.mkdir(parents=True)
for invalid in ("true", 1, None, [], {}): for invalid in ("true", 1, None, [], {}):
original = json.dumps({"lock_layout": invalid, "font": "/custom/font.ttf"}).encode() original = json.dumps({"lock_layout": invalid, "font": "/custom/font.ttf"}).encode()
config.write_bytes(original) config.write_bytes(original)
self.install("v1", archive, digest) self.install("0.1.202609241530", archive, digest)
fixed = json.loads(config.read_text()) fixed = json.loads(config.read_text())
self.assertIs(fixed["lock_layout"], False) self.assertIs(fixed["lock_layout"], False)
self.assertEqual(fixed["font"], "/custom/font.ttf") self.assertEqual(fixed["font"], "/custom/font.ttf")
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
def test_new_config_fields_repair_only_invalid_values(self):
archive, digest = self.package("0.1.202609241530")
config = self.home / ".config/frameyap/config.json"
config.parent.mkdir(parents=True)
for invalid in ("true", 1, None, [], {}):
original = json.dumps({"close_mic_when_idle": invalid, "backend": "../unsafe"}).encode()
config.write_bytes(original)
self.install("0.1.202609241530", archive, digest)
fixed = json.loads(config.read_text())
self.assertIs(fixed["close_mic_when_idle"], False)
self.assertEqual(fixed["backend"], "redux")
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
for value in ("a", "a" + "9" * 47, "custom_backend-2"):
self.assertEqual(installer.normalized_config({"backend": value})["backend"], value)
for invalid in ("", "9bad", "a" * 49, "UPPER", "a/b", 12):
self.assertEqual(installer.normalized_config({"backend": invalid})["backend"], "redux")
def test_digest_and_same_version_mismatch_leave_previous(self): def test_digest_and_same_version_mismatch_leave_previous(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
root = self.data / "frameyap" root = self.data / "frameyap"
with self.assertRaisesRegex(ValueError, "SHA-256 mismatch"): with self.assertRaisesRegex(ValueError, "SHA-256 mismatch"):
self.install("v1", a, "0" * 64) self.install("0.1.202609241530", a, "0" * 64)
a.unlink() a.unlink()
(self.output / (a.name + ".sha256")).unlink() (self.output / (a.name + ".sha256")).unlink()
(self.stage / "bin/frameyap").write_text("changed") (self.stage / "bin/frameyap").write_text("changed")
a, h2 = self.package("v1") a, h2 = self.package("0.1.202609241530")
with self.assertRaisesRegex(ValueError, "different archive digest"): with self.assertRaisesRegex(ValueError, "different archive digest"):
self.install("v1", a, h2) self.install("0.1.202609241530", a, h2)
self.assertEqual(os.readlink(root / "current"), "versions/v1") self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530")
def test_without_model_lock_and_foreign_launcher(self): def test_without_model_lock_and_foreign_launcher(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
lock = self.data / "frameyap/.lock" lock = self.data / "frameyap/.lock"
lock.parent.mkdir(parents=True) lock.parent.mkdir(parents=True)
with lock.open("a+b") as fd: with lock.open("a+b") as fd:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(ValueError, "running"): with self.assertRaisesRegex(ValueError, "running"):
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
root = self.data / "frameyap" root = self.data / "frameyap"
self.assertFalse((root / "versions/v1/model").exists()) self.assertFalse((root / "versions/0.1.202609241530/model").exists())
self.assertTrue((self.stage / "model/weights.bin").exists()) self.assertTrue((self.stage / "model/weights.bin").exists())
self.assertEqual(json.loads((root / "versions/v1/.install-options.json").read_text()), self.assertEqual(json.loads((root / "versions/0.1.202609241530/.install-options.json").read_text()),
{"without_model": True}) {"without_model": True})
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
self.assertFalse((root / "versions/v1/model").exists()) self.assertFalse((root / "versions/0.1.202609241530/model").exists())
with self.assertRaisesRegex(ValueError, "different --without-model choice"): with self.assertRaisesRegex(ValueError, "different --without-model choice"):
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
self.assertFalse((root / "versions/v1/model").exists()) self.assertFalse((root / "versions/0.1.202609241530/model").exists())
(root / "frameyap.vrmanifest").unlink() (root / "frameyap.vrmanifest").unlink()
launcher = self.home / ".local/bin/frameyap" launcher = self.home / ".local/bin/frameyap"
launcher.unlink() launcher.unlink()
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
self.assertTrue(launcher.exists()) self.assertTrue(launcher.exists())
self.assertTrue((root / "frameyap.vrmanifest").exists()) self.assertTrue((root / "frameyap.vrmanifest").exists())
(root / "frameyap.vrmanifest").write_text("foreign") (root / "frameyap.vrmanifest").write_text("foreign")
with self.assertRaisesRegex(ValueError, "foreign file"): with self.assertRaisesRegex(ValueError, "foreign file"):
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
(root / "frameyap.vrmanifest").unlink() (root / "frameyap.vrmanifest").unlink()
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
(self.home / ".local/bin/frameyap").write_text("#!/bin/sh\n" + installer.MARKER + "echo foreign\n") (self.home / ".local/bin/frameyap").write_text("#!/bin/sh\n" + installer.MARKER + "echo foreign\n")
(self.stage / "bin/frameyap").write_text("new") (self.stage / "bin/frameyap").write_text("new")
a2, h2 = self.package("v2") a2, h2 = self.package("0.1.202609241531")
with self.assertRaisesRegex(ValueError, "foreign file"): with self.assertRaisesRegex(ValueError, "foreign file"):
self.install("v2", a2, h2) self.install("0.1.202609241531", a2, h2)
self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/v1") self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241530")
self.assertFalse((self.data / "frameyap/versions/v2").exists()) self.assertFalse((self.data / "frameyap/versions/0.1.202609241531").exists())
def test_foreign_desktop_entry_is_preserved(self): def test_foreign_desktop_entry_is_preserved(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
desktop = self.data / "applications/frameyap.desktop" desktop = self.data / "applications/frameyap.desktop"
desktop.parent.mkdir(parents=True) desktop.parent.mkdir(parents=True)
desktop.write_text("foreign shortcut\n") desktop.write_text("foreign shortcut\n")
with self.assertRaisesRegex(ValueError, "foreign file"): with self.assertRaisesRegex(ValueError, "foreign file"):
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
self.assertEqual(desktop.read_text(), "foreign shortcut\n") self.assertEqual(desktop.read_text(), "foreign shortcut\n")
self.assertFalse((self.data / "frameyap/current").exists()) self.assertFalse((self.data / "frameyap/current").exists())
@@ -309,8 +363,8 @@ class InstallTests(unittest.TestCase):
self.assertIn('Exec="/home/steam%%user/.local/bin/frameyap"', entry) self.assertIn('Exec="/home/steam%%user/.local/bin/frameyap"', entry)
def test_traversal_and_link_archives_rejected(self): def test_traversal_and_link_archives_rejected(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
for badname, kind in (("../outside", "file"), ("bin/escape", "symlink"), for badname, kind in (("../outside", "file"), ("bin/escape", "symlink"),
("/absolute", "file"), ("bin/escape", "hardlink")): ("/absolute", "file"), ("bin/escape", "hardlink")):
with self.subTest(badname=badname, kind=kind): with self.subTest(badname=badname, kind=kind):
@@ -326,8 +380,8 @@ class InstallTests(unittest.TestCase):
tar.addfile(info, io.BytesIO(b"x")) tar.addfile(info, io.BytesIO(b"x"))
sha = hashlib.sha256(bad.read_bytes()).hexdigest() sha = hashlib.sha256(bad.read_bytes()).hexdigest()
with self.assertRaisesRegex(ValueError, "unsafe archive|forbidden"): with self.assertRaisesRegex(ValueError, "unsafe archive|forbidden"):
self.install("v2", bad, sha) self.install("0.1.202609241531", bad, sha)
self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/v1") self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241530")
self.assertFalse((self.base / "outside").exists()) self.assertFalse((self.base / "outside").exists())
def test_unexpected_root_entries_and_oversized_metadata_rejected(self): def test_unexpected_root_entries_and_oversized_metadata_rejected(self):
@@ -340,34 +394,34 @@ class InstallTests(unittest.TestCase):
tar.addfile(info, io.BytesIO(b"x" * size)) tar.addfile(info, io.BytesIO(b"x" * size))
sha = hashlib.sha256(bad.read_bytes()).hexdigest() sha = hashlib.sha256(bad.read_bytes()).hexdigest()
with self.assertRaisesRegex(ValueError, "unexpected archive path|oversized release metadata"): with self.assertRaisesRegex(ValueError, "unexpected archive path|oversized release metadata"):
self.install("v1", bad, sha) self.install("0.1.202609241530", bad, sha)
def test_uninstall_refuses_untracked_files(self): def test_uninstall_refuses_untracked_files(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
root = self.data / "frameyap" root = self.data / "frameyap"
extra = root / "versions/v1/user.txt" extra = root / "versions/0.1.202609241530/user.txt"
extra.write_text("preserve") extra.write_text("preserve")
with self.assertRaisesRegex(ValueError, "untracked files"): with self.assertRaisesRegex(ValueError, "untracked files"):
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
installer.main(["--uninstall", "--unregistered"]) installer.main(["--uninstall", "--unregistered"])
self.assertTrue(extra.exists()) self.assertTrue(extra.exists())
self.assertEqual(os.readlink(root / "current"), "versions/v1") self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530")
def test_release_metadata_mismatch_retains_current(self): def test_release_metadata_mismatch_retains_current(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
a2, h2 = self.package("v2") a2, h2 = self.package("0.1.202609241531")
with self.assertRaisesRegex(ValueError, "metadata version/architecture/schema mismatch"): with self.assertRaisesRegex(ValueError, "metadata version/architecture/schema mismatch"):
self.install("v3", a2, h2) self.install("0.1.202609241532", a2, h2)
self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/v1") self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241530")
def test_launcher_defaults_run_but_forwards_registration(self): def test_launcher_defaults_run_but_forwards_registration(self):
path = self.stage / "bin/frameyap" path = self.stage / "bin/frameyap"
path.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\nprintf "ENV:%s\\n" "${PYTHONDONTWRITEBYTECODE:-}"\n') path.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\nprintf "ENV:%s\\n" "${PYTHONDONTWRITEBYTECODE:-}"\n')
path.chmod(0o755) path.chmod(0o755)
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
launcher = self.home / ".local/bin/frameyap" launcher = self.home / ".local/bin/frameyap"
reg = subprocess.run([str(launcher), "--register", "test-manifest"], capture_output=True, text=True, check=True) reg = subprocess.run([str(launcher), "--register", "test-manifest"], capture_output=True, text=True, check=True)
self.assertEqual(reg.stdout.splitlines(), ["--register", "test-manifest", "ENV:1"]) self.assertEqual(reg.stdout.splitlines(), ["--register", "test-manifest", "ENV:1"])
@@ -377,33 +431,33 @@ class InstallTests(unittest.TestCase):
self.assertIn("--assets", run.stdout) self.assertIn("--assets", run.stdout)
self.assertIn("--socket\nfixture-socket\n", run.stdout) self.assertIn("--socket\nfixture-socket\n", run.stdout)
self.assertIn("ENV:1", run.stdout) self.assertIn("ENV:1", run.stdout)
self.assertEqual(json.loads((self.data / "frameyap/versions/v1/.install-options.json").read_text()), self.assertEqual(json.loads((self.data / "frameyap/versions/0.1.202609241530/.install-options.json").read_text()),
{"without_model": False}) {"without_model": False})
with self.assertRaisesRegex(ValueError, "different --without-model choice"): with self.assertRaisesRegex(ValueError, "different --without-model choice"):
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
self.assertTrue((self.data / "frameyap/versions/v1/model/weights.bin").exists()) self.assertTrue((self.data / "frameyap/versions/0.1.202609241530/model/weights.bin").exists())
def test_no_model_reinstall_preserves_provisioned_model_and_uninstall(self): def test_no_model_reinstall_preserves_provisioned_model_and_uninstall(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
root = self.data / "frameyap" root = self.data / "frameyap"
model = root / "versions/v1/model" model = root / "versions/0.1.202609241530/model"
model.mkdir() model.mkdir()
(model / "provided.bin").write_text("user-provided") (model / "provided.bin").write_text("user-provided")
self.install("v1", a, h, "--without-model") self.install("0.1.202609241530", a, h, "--without-model")
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
installer.main(["--uninstall", "--unregistered"]) installer.main(["--uninstall", "--unregistered"])
self.assertEqual((root / "saved-models/v1/provided.bin").read_text(), "user-provided") self.assertEqual((root / "saved-models/0.1.202609241530/provided.bin").read_text(), "user-provided")
def test_version_switch_rejects_untracked_installed_files(self): def test_version_switch_rejects_untracked_installed_files(self):
a, h = self.package("v1") a, h = self.package("0.1.202609241530")
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
(self.data / "frameyap/versions/v1/untracked").write_text("keep") (self.data / "frameyap/versions/0.1.202609241530/untracked").write_text("keep")
a2, h2 = self.package("v2") a2, h2 = self.package("0.1.202609241531")
with self.assertRaisesRegex(ValueError, "untracked files"): with self.assertRaisesRegex(ValueError, "untracked files"):
self.install("v1", a, h) self.install("0.1.202609241530", a, h)
# Upgrade does not delete the old directory, but uninstall must still refuse it. # Upgrade does not delete the old directory, but uninstall must still refuse it.
self.install("v2", a2, h2) self.install("0.1.202609241531", a2, h2)
with self.assertRaisesRegex(ValueError, "untracked files"): with self.assertRaisesRegex(ValueError, "untracked files"):
installer.main(["--uninstall", "--unregistered"]) installer.main(["--uninstall", "--unregistered"])
@@ -413,8 +467,8 @@ class InstallTests(unittest.TestCase):
native = self.stage / "bin/frameyap" native = self.stage / "bin/frameyap"
native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n')
native.chmod(0o755) native.chmod(0o755)
a, h = self.package("external", "--external-runtime") a, h = self.package("0.1.202609241533", "--external-runtime")
self.install("external", a, h) self.install("0.1.202609241533", a, h)
root = self.data / "frameyap/current" root = self.data / "frameyap/current"
self.assertEqual(json.loads((root / "release.json").read_text())["runtime"], "external-authorized-python") self.assertEqual(json.loads((root / "release.json").read_text())["runtime"], "external-authorized-python")
self.assertFalse((root / "runtime").exists()) self.assertFalse((root / "runtime").exists())
@@ -432,14 +486,14 @@ class InstallTests(unittest.TestCase):
native = self.stage / "bin/frameyap" native = self.stage / "bin/frameyap"
native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n')
native.chmod(0o755) native.chmod(0o755)
a, h = self.package("external", "--external-runtime") a, h = self.package("0.1.202609241533", "--external-runtime")
self.install("external", a, h) self.install("0.1.202609241533", a, h)
root = self.data / "frameyap" root = self.data / "frameyap"
launcher = self.home / ".local/bin/frameyap" launcher = self.home / ".local/bin/frameyap"
# A pre-config release launcher may be upgraded only when its exact # A pre-config release launcher may be upgraded only when its exact
# bytes match the managed legacy template, not just its marker. # bytes match the managed legacy template, not just its marker.
launcher.write_bytes(installer.desired_launcher(root, legacy=True)) launcher.write_bytes(installer.desired_launcher(root, legacy=True))
self.install("external", a, h) self.install("0.1.202609241533", a, h)
self.assertEqual(launcher.read_bytes(), installer.desired_launcher(root)) self.assertEqual(launcher.read_bytes(), installer.desired_launcher(root))
config = self.home / ".config/frameyap/paths.conf" config = self.home / ".config/frameyap/paths.conf"
config.parent.mkdir(parents=True, exist_ok=True) config.parent.mkdir(parents=True, exist_ok=True)
@@ -458,24 +512,402 @@ class InstallTests(unittest.TestCase):
# A user-modified launcher must remain protected, even with the marker. # A user-modified launcher must remain protected, even with the marker.
launcher.write_bytes(installer.desired_launcher(root, legacy=True) + b"# changed\n") launcher.write_bytes(installer.desired_launcher(root, legacy=True) + b"# changed\n")
with self.assertRaisesRegex(ValueError, "foreign file"): with self.assertRaisesRegex(ValueError, "foreign file"):
self.install("external", a, h) self.install("0.1.202609241533", a, h)
def test_native_stage_notices_credit_system_freetype_without_bundling(self):
spec = importlib.util.spec_from_file_location("stage_native", REPO / "scripts/stage-native.py")
stage_native = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stage_native)
build = self.base / "native-build"
build.mkdir()
(build / "CMakeCache.txt").write_text("FRAMEYAP_NATIVE:BOOL=ON\n")
dest = self.base / "native-stage"
args = ["stage-native.py", "--build", str(build), "--destination", str(dest)]
for flag in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
args += ["--" + flag, str(self.stage / "lib/libtest.so")]
def fake_install(*_args, **_kwargs):
(dest / "bin").mkdir(parents=True)
with patch.object(stage_native.subprocess, "run", side_effect=fake_install), \
patch.object(sys, "argv", args), contextlib.redirect_stdout(io.StringIO()):
stage_native.main()
notice = (dest / "licenses/THIRD_PARTY_NOTICES.txt").read_text()
self.assertIn("This software is based in part on the work of the FreeType Team.", notice)
self.assertIn("FreeType is a system dynamic library, not bundled", notice)
self.assertIn("Bundled libraries: Valve OpenVR and unmodified SDL3", notice)
self.assertFalse(any(dest.glob("lib/*FreeType*")))
def test_package_rejects_symlink(self): def test_package_rejects_symlink(self):
(self.stage / "lib/link.so").symlink_to("libtest.so") (self.stage / "lib/link.so").symlink_to("libtest.so")
result = subprocess.run([sys.executable, str(REPO / "scripts/package-release.py"), "--stage", str(self.stage), result = subprocess.run([sys.executable, str(REPO / "scripts/package-release.py"), "--stage", str(self.stage),
"--output", str(self.output), "--arch", "linux-aarch64", "--version", "v1", "--output", str(self.output), "--arch", "linux-aarch64", "--version", "0.1.202609241530",
"--model-revision", "test"], capture_output=True, text=True) "--model-revision", "test"], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("links and special files forbidden", result.stderr) self.assertIn("links and special files forbidden", result.stderr)
def test_utc_timestamp_release_tag_roundtrip(self): def test_numeric_utc_release_version_roundtrip(self):
version = "2026-09-24T162712Z-g417f81c-dirty" for version in ("0.1.202609241627", "2.13.202609241628", "12.0.202609241629"):
archive, digest = self.package(version) with self.subTest(version=version):
self.assertEqual(archive.name, f"frameyap-{version}-linux-aarch64.tar.gz") archive, digest = self.package(version)
self.install(version, archive, digest) self.assertEqual(archive.name, f"frameyap-{version}-linux-aarch64.tar.gz")
self.install(version, archive, digest)
root = self.data / "frameyap"
self.assertEqual(os.readlink(root / "current"), f"versions/{version}")
self.assertEqual(json.loads((root / "current/release.json").read_text())["version"], version)
def test_autolaunch_is_opt_in_and_registration_is_after_lock(self):
native = self.stage / "bin/frameyap"
native.write_text('#!/usr/bin/env python3\n'
'import fcntl, os, pathlib, sys\n'
'assert sys.argv[1] == "--register"\n'
'pathlib.Path(os.environ["HOME"], "register-args").write_text("\\n".join(sys.argv[1:])+"\\n")\n'
'with pathlib.Path(os.environ["XDG_DATA_HOME"], "frameyap/.lock").open("a+b") as f:\n'
' fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)\n')
native.chmod(0o755)
archive, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", archive, digest)
record = self.home / "register-args"
self.assertFalse(record.exists())
self.install("0.1.202609241530", archive, digest, "--autolaunch")
self.assertEqual(record.read_text().splitlines()[-1], "--autostart")
self.install("0.1.202609241530", archive, digest, "--no-autolaunch")
self.assertEqual(record.read_text().splitlines(),
["--register", str(self.data / "frameyap/frameyap.vrmanifest")])
def test_numeric_package_rejects_git_suffix_bad_date_and_tag(self):
for version in ("v0.1.202609241530", "0.1.202609241530-gabc123", "0.1.202613241530",
"01.1.202609241530", "1.01.202609241530", "2.13.202613241530"):
with self.subTest(version=version):
result = subprocess.run([sys.executable, str(REPO / "scripts/package-release.py"),
"--stage", str(self.stage), "--output", str(self.output), "--arch", "linux-aarch64",
"--version", version, "--model-revision", "fixture"], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
with self.assertRaisesRegex(ValueError, "version|date/time"):
installer.check_version(version)
def test_piped_wrapper_json_usage_error_never_reads_stdin(self):
result = subprocess.run(["sh", str(REPO / "install.sh"), "--mode", "source", "--json"],
input="unused and unread", text=True, capture_output=True,
timeout=8, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
self.assertEqual(result.returncode, 2)
self.assertEqual(json.loads(result.stdout)["code"], "usage")
self.assertEqual(result.stderr, "")
self.assertFalse((self.data / "frameyap").exists())
def test_piped_input_with_tty_output_does_not_prompt(self):
master, slave = pty.openpty()
try:
child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE,
stdout=slave, stderr=slave,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
os.close(slave)
slave = -1
output = bytearray()
try:
child.stdin.write(b"source\n")
child.stdin.close()
while child.poll() is None:
ready, _, _ = select.select([master], [], [], 8)
self.assertTrue(ready, "piped installer did not exit")
try:
output.extend(os.read(master, 8192))
except OSError:
break
self.assertEqual(child.wait(timeout=8), 2)
self.assertNotIn(b"Mode [binary/source]:", output)
self.assertFalse((self.data / "frameyap").exists())
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=8)
finally:
os.close(master)
if slave >= 0:
os.close(slave)
def test_attended_shell_wrapper_reads_the_actual_tty(self):
master, slave = pty.openpty()
try:
child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=slave,
stdout=slave, stderr=slave,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
os.close(slave)
slave = -1
output = bytearray()
try:
os.write(master, b"not-a-mode\n")
while child.poll() is None:
ready, _, _ = select.select([master], [], [], 8)
self.assertTrue(ready, "installer did not return from terminal input")
try:
output.extend(os.read(master, 8192))
except OSError:
break
self.assertEqual(child.wait(timeout=8), 2)
self.assertIn(b"Mode [binary/source]:", output)
self.assertIn(b"choose binary or source", output)
self.assertFalse((self.data / "frameyap").exists())
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=8)
finally:
os.close(master)
if slave >= 0:
os.close(slave)
def test_plan_and_json_errors_do_not_install_or_prompt(self):
root = self.data / "frameyap" root = self.data / "frameyap"
self.assertEqual(os.readlink(root / "current"), f"versions/{version}") response = io.StringIO()
self.assertEqual(json.loads((root / "current/release.json").read_text())["version"], version) with contextlib.redirect_stdout(response):
self.assertEqual(installer.cli(["--mode", "binary", "--version", "0.1.202609241530",
"--print-plan", "--json"]), 0)
result = json.loads(response.getvalue())
self.assertEqual(result["event"], "plan")
self.assertEqual(result["tag"], "v0.1.202609241530")
self.assertTrue(result["network"])
self.assertFalse(root.exists())
response = io.StringIO()
with contextlib.redirect_stdout(response):
self.assertEqual(installer.cli(["--mode", "source", "--json"]), 2)
error = json.loads(response.getvalue())
self.assertEqual((error["code"], error["exit_code"]), ("usage", 2))
self.assertFalse(root.exists())
response = io.StringIO()
with contextlib.redirect_stdout(response):
self.assertEqual(installer.cli(["--version", "0.1.202609241530", "--json"]), 1)
self.assertIn("--yes", json.loads(response.getvalue())["message"])
self.assertFalse(root.exists())
def test_json_plan_rejects_invalid_installed_manifest_without_mutation(self):
manifests = self.stage / "assets/backends"
manifests.mkdir()
shutil.copyfile(REPO / "python/frameyap/model_files.py",
self.stage / "python/frameyap/model_files.py")
(manifests / "redux.json").write_text('{"broken": true}')
archive, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", archive, digest, "--without-model")
root = self.data / "frameyap"
original = sorted(p.relative_to(root).as_posix() for p in root.rglob("*"))
response = io.StringIO()
with contextlib.redirect_stdout(response):
self.assertEqual(installer.cli(["--print-plan", "--install-model", "--backend", "redux", "--json"]), 1)
self.assertEqual(json.loads(response.getvalue())["code"], "operation_failed")
self.assertEqual(original, sorted(p.relative_to(root).as_posix() for p in root.rglob("*")))
def test_running_app_and_model_provisioning_locks_preserve_session(self):
first, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", first, digest)
root = self.data / "frameyap"
next_version = "0.1.202609241531"
second, digest2 = self.package(next_version)
with (root / ".model.lock").open("a+b") as provision_lock:
fcntl.flock(provision_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(ValueError, "model provisioning is running"):
self.install(next_version, second, digest2)
with (root / ".lock").open("a+b") as app_lock:
fcntl.flock(app_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(ValueError, "application is running"):
self.install(next_version, second, digest2)
self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530")
self.assertFalse((root / "versions" / next_version).exists())
def test_source_preflight_refuses_missing_tools_before_build(self):
sources = self.base / "sources"
(sources / "scripts").mkdir(parents=True)
for name in ("CMakeLists.txt", "scripts/install-preflight.sh", "scripts/stage-native.py", "scripts/package-release.py"):
(sources / name).write_text("fixture")
sdk = self.base / "sdk"
(sdk / "headers").mkdir(parents=True)
(sdk / "headers/openvr.h").write_text("fixture")
argv = ["--mode", "source", "--source", str(sources), "--openvr-root", str(sdk),
"--version", "0.1.202609241530"]
for flag in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
argv += ["--" + flag, str(self.stage / "bin/frameyap")]
self.assertFalse(installer.plan(installer.resolve_args(argv))["network"])
with patch.object(installer.shutil, "which", return_value=None), patch.object(installer.subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "source prerequisite missing: cmake"):
installer.main(argv)
run.assert_not_called()
self.assertFalse((self.data / "frameyap/current").exists())
# A local source tree must pass its own read-only shell preflight; do
# not create an install root or start a build when it reports failure.
with patch.object(installer.shutil, "which", return_value="/mock/tool"), \
patch.object(installer.subprocess, "run", return_value=SimpleNamespace(returncode=1, stderr="missing Vulkan")) as run:
with self.assertRaisesRegex(ValueError, "source preflight failed.*missing Vulkan"):
installer.main(argv)
self.assertEqual(run.call_args.args[0], ["sh", str(sources / "scripts/install-preflight.sh"), "--source"])
self.assertFalse((self.data / "frameyap").exists())
def test_source_mode_packages_local_build_and_keeps_rollback(self):
previous, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", previous, digest)
candidate, _ = self.package("0.1.202609241531")
sources = self.base / "source"
(sources / "scripts").mkdir(parents=True)
for name in ("CMakeLists.txt", "scripts/install-preflight.sh", "scripts/stage-native.py", "scripts/package-release.py"):
(sources / name).write_text("fixture")
sdk = self.base / "sdk"
(sdk / "headers").mkdir(parents=True)
(sdk / "headers/openvr.h").write_text("fixture")
argv = ["--mode", "source", "--source", str(sources), "--openvr-root", str(sdk),
"--version", "0.1.202609241531"]
for flag in ("openvr-library", "openvr-license", "sdl-library", "sdl-license"):
argv += ["--" + flag, str(self.stage / "bin/frameyap")]
(sources / "assets/backends").mkdir(parents=True)
source_manifest = json.loads((REPO / "assets/backends/redux.json").read_text())
source_manifest["model"]["revision"] = "a" * 40
(sources / "assets/backends/redux.json").write_text(json.dumps(source_manifest))
calls = []
def fake_command(command, **kwargs):
calls.append(command)
if "--output" in command:
target = Path(command[command.index("--output") + 1]) / candidate.name
shutil.copyfile(candidate, target)
return SimpleNamespace(returncode=0, stderr="", stdout="")
with patch.object(installer.shutil, "which", return_value="/mock/tool"), \
patch.object(installer.subprocess, "run", side_effect=fake_command), \
contextlib.redirect_stdout(io.StringIO()):
installer.main(argv)
self.assertEqual([cmd[0] for cmd in calls[-4:]],
["cmake", "cmake", sys.executable, sys.executable])
self.assertIn("-DFRAMEYAP_NATIVE=ON", calls[-4])
self.assertIn("-DFRAMEYAP_VERSION=0.1.202609241531", calls[-4])
self.assertIn(["sh", str(sources / "scripts/install-preflight.sh"), "--source"], calls)
self.assertEqual(calls[-1][calls[-1].index("--model-revision") + 1], "a" * 40)
self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241531")
self.assertEqual(os.readlink(self.data / "frameyap/previous"), "versions/0.1.202609241530")
def test_expected_installed_manifest_hash_is_id_bound_and_read_only_on_mismatch(self):
shutil.copyfile(REPO / "python/frameyap/model_files.py",
self.stage / "python/frameyap/model_files.py")
manifest = json.loads((REPO / "assets/backends/redux.json").read_text())
payload = b"pinned local test bytes"
manifest["model"]["files"] = [{"path": "weights.bin", "size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest()}]
manifests = self.stage / "assets/backends"
manifests.mkdir()
# Deliberate formatting: the contract is raw bytes, not canonical JSON.
raw = (json.dumps(manifest, indent=3) + "\n").encode()
(manifests / "redux.json").write_bytes(raw)
other = {**manifest, "id": "other", "display_name": "Other fixture backend"}
(manifests / "other.json").write_text(json.dumps(other))
archive, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", archive, digest, "--without-model")
root = self.data / "frameyap"
installed_raw = (root / "current/assets/backends/redux.json").read_bytes()
self.assertEqual(installed_raw, raw)
expected = hashlib.sha256(installed_raw).hexdigest()
other_sha = hashlib.sha256((root / "current/assets/backends/other.json").read_bytes()).hexdigest()
self.assertNotEqual(other_sha, expected)
destination = self.base / "never-created"
argv = ["--install-model", "--backend", "redux", "--model-dir", str(destination),
"--expected-manifest-sha256", "0" * 64, "--json"]
with patch.object(installer.urllib.request, "urlopen") as fetch:
for extra in (["--yes"], ["--print-plan"]):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(argv + extra), 1)
error = json.loads(output.getvalue())
self.assertEqual((error["code"], error["exit_code"]), ("manifest_mismatch", 1))
self.assertIn("redux.json", error["message"])
fetch.assert_not_called()
self.assertFalse(destination.exists())
self.assertFalse((root / "models").exists())
with (root / ".model.lock").open("a+b") as provision_lock:
fcntl.flock(provision_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(ValueError, r"\.model\.lock held"):
installer.main(argv + ["--yes"])
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(argv[:-3] + ["--expected-manifest-sha256", "bad", "--yes", "--json"]), 2)
self.assertEqual(json.loads(output.getvalue())["code"], "usage")
self.assertFalse(destination.exists())
correct = argv[:-3] + ["--expected-manifest-sha256", expected.upper(), "--print-plan", "--json"]
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(correct), 0)
plan = json.loads(output.getvalue())
self.assertEqual(plan["installed_manifest_sha256"], expected)
self.assertEqual(plan["expected_manifest_sha256"], expected)
self.assertFalse(destination.exists())
destination.mkdir()
(destination / "weights.bin").write_bytes(payload)
with patch.object(installer.urllib.request, "urlopen") as fetch:
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(correct[:-2] + ["--yes", "--json"]), 0)
fetch.assert_not_called()
self.assertEqual([json.loads(line)["event"] for line in output.getvalue().splitlines()],
["model_file", "complete"])
# The digest of a different valid manifest cannot authorize this ID.
with patch.object(installer.urllib.request, "urlopen") as fetch:
for selected_id, wrong_sha in (("redux", other_sha), ("other", expected)):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(["--install-model", "--backend", selected_id,
"--expected-manifest-sha256", wrong_sha, "--yes", "--json"]), 1)
self.assertEqual(json.loads(output.getvalue())["code"], "manifest_mismatch")
fetch.assert_not_called()
self.assertFalse((root / "models/other").exists())
def test_model_install_manifest_verification_and_explicit_consent(self):
# Tiny pinned files via the shared verifier; urllib is mocked, no network.
source_module = REPO / "python/frameyap/model_files.py"
staged_module = self.stage / "python/frameyap/model_files.py"
shutil.copyfile(source_module, staged_module)
manifest = json.loads((REPO / "assets/backends/redux.json").read_text())
payload = b"tiny pinned model fixture"
manifest["model"]["files"] = [{"path": "sub/weights.bin", "size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest()}]
manifests = self.stage / "assets/backends"
manifests.mkdir()
(manifests / "redux.json").write_text(json.dumps(manifest))
archive, digest = self.package("0.1.202609241530")
self.install("0.1.202609241530", archive, digest, "--without-model")
destination = self.base / "models"
argv = ["--install-model", "--backend", "redux", "--model-dir", str(destination), "--json"]
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(argv), 1)
self.assertIn("--yes", json.loads(output.getvalue())["message"])
self.assertFalse(destination.exists())
with (self.data / "frameyap/.model.lock").open("a+b") as provision_lock:
fcntl.flock(provision_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(ValueError, "model provisioning is running"):
installer.main(["--rollback"])
class Response(io.BytesIO):
def geturl(self):
return "https://huggingface.co/fixture"
with patch.object(installer.urllib.request, "urlopen", return_value=Response(b"x" * len(payload))):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(argv + ["--yes"]), 1)
self.assertIn("SHA-256/size mismatch", output.getvalue())
self.assertFalse((destination / "sub/weights.bin").exists())
with patch.object(installer.urllib.request, "urlopen", return_value=Response(payload)) as fetch, \
(self.data / "frameyap/.lock").open("a+b") as app_lock:
fcntl.flock(app_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) # UI's running app
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(argv + ["--yes"]), 0)
events = [json.loads(line) for line in output.getvalue().splitlines()]
self.assertEqual([event["event"] for event in events],
["model_file", "model_file", "complete"])
self.assertEqual((destination / "sub/weights.bin").read_bytes(), payload)
self.assertEqual(fetch.call_count, 1)
with patch.object(installer.urllib.request, "urlopen") as fetch:
repeated = io.StringIO()
with contextlib.redirect_stdout(repeated):
repeated_code = installer.cli(argv + ["--yes"])
self.assertEqual(repeated_code, 0, repeated.getvalue())
fetch.assert_not_called()
(destination / "sub/weights.bin").write_bytes(b"bad")
with patch.object(installer.urllib.request, "urlopen") as fetch:
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(installer.cli(argv + ["--yes"]), 1)
self.assertIn("mismatched", json.loads(output.getvalue())["message"])
fetch.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":