diff --git a/install.sh b/install.sh index 3f18004..dc6ecd3 100755 --- a/install.sh +++ b/install.sh @@ -1,16 +1,29 @@ #!/bin/sh # FrameYap pinned release installer. No implicit version, downloads, or runtime launch. set -eu -for tool in python3 curl sha256sum tar; do - command -v "$tool" >/dev/null 2>&1 || { echo "frameyap installer: missing prerequisite: $tool" >&2; exit 1; } -done -python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' || { - echo 'frameyap installer: Python 3.12+ required for bootstrap only (release bundles runtime)' >&2 +json=false +for arg in "$@"; do [ "$arg" = --json ] && json=true; done +bootstrap_error() { + if [ "$json" = true ]; then + printf '{"ok":false,"code":"bootstrap","message":"%s","exit_code":1}\n' "$1" + else + printf 'frameyap installer: %s\n' "$1" >&2 + fi 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.""" import argparse +import contextlib +from datetime import datetime +import io +import importlib.util import fcntl import hashlib import json @@ -18,24 +31,28 @@ import os from pathlib import Path import platform import re +import shlex import shutil import subprocess import sys import tarfile import tempfile from urllib.parse import quote +import urllib.request KEY = "local.frameyap.overlay" MARKER = "# FrameYap managed launcher v1\n" ARCHIVE_LIMIT = 12 * 1024**3 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") CONFIG_DEFAULTS = { "font": "", "input_priority": "normal", "advanced_debug": False, "auto_insert": False, + "close_mic_when_idle": False, + "backend": "redux", "lock_layout": False, "clock_24h": False, "date_format": "mdy", @@ -52,6 +69,7 @@ CONFIG_DEFAULTS = { } 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") +BACKEND_RE = re.compile(r"[a-z][a-z0-9_-]{0,47}\Z", re.ASCII) def fail(message): @@ -103,6 +121,10 @@ def normalized_config(data): fixed["advanced_debug"] = debug if type(debug) is bool else False automatic = data.get("auto_insert", 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) fixed["lock_layout"] = layout if type(layout) is bool else False clock = data.get("clock_24h", False) @@ -204,8 +226,12 @@ def check_digest(value): def check_version(value): - if not VERSION_RE.fullmatch(value) or value in (".", ".."): - fail("invalid release version/tag") + if not VERSION_RE.fullmatch(value): + 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 @@ -246,7 +272,7 @@ def verify_members(archive): total += member.size if total > ARCHIVE_LIMIT: 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 (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())): fail(f"unexpected archive path: {name}") @@ -284,9 +310,11 @@ def validate_payload(root, version, without_model=False, installed=False): fail("missing declared model") if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists(): 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": 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(): fail(f"missing payload file: {name}") for name in ("lib", "fonts"): @@ -448,6 +476,194 @@ def installed_choice(path): 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): version = check_version(args.version) versions = root / "versions" @@ -457,17 +673,20 @@ def do_install(args, root, launcher): # Refuse foreign wrappers/launcher directories before installing a new version. owned_dir(launcher.parent) 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: + 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: 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 sidecar = Path(td) / (name + ".sha256") 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.") -def main(argv=None): - parser = argparse.ArgumentParser(prog="install.sh", description="User-local FrameYap release installer (no SteamVR actions)") - 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") - mode.add_argument("--uninstall", action="store_true") +class UsageError(ValueError): + pass + + +class InstallerParser(argparse.ArgumentParser): + 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("--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("--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("--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") args = parser.parse_args(argv) if args.repo != "baketnk/frame-yap": 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): 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: parser.error("--sha256 only applies to --archive") - if not (args.rollback or args.uninstall or args.archive) and not args.version: - parser.error("--version TAG is required; no moving/latest release") - if args.uninstall and not args.unregistered: - parser.error("uninstall requires --unregistered after explicit OpenVR unregister") - if args.unregistered and not args.uninstall: - parser.error("--unregistered only applies to --uninstall") + if not (args.rollback or args.uninstall or args.install_model) and not args.version: + parser.error("--version VERSION is required; no moving/latest release") + if args.uninstall != args.unregistered: + parser.error("--uninstall requires --unregistered after explicit OpenVR unregister") + if args.model_dir and not args.install_model: + 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: - 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() + 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() root = data / "frameyap" launcher = Path.home() / ".local/bin/frameyap" 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(): fail("refusing symlink lock") with lock.open("a+b") as fd: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: - fail("FrameYap installer or application is running (.lock held); try again later") - if args.uninstall: - uninstall(root, launcher) - elif args.rollback: - owned_dir(root / "versions") - 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}") + fail(f"FrameYap installer or application is running ({lock.name} held); try again later") + other = root / ".model.lock" + if not args.install_model and other.is_symlink(): + fail("refusing symlink model lock") + with (contextlib.nullcontext() if args.install_model else other.open("a+b")) as model_fd: + if model_fd is not None: + try: + fcntl.flock(model_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + fail("model provisioning is running (.model.lock held); retry later") + if args.uninstall: + uninstall(root, launcher) + elif args.rollback: + owned_dir(root / "versions") + 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: - 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__": - try: - main() - except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError) as exc: - print(f"frameyap installer: {exc}", file=sys.stderr) - sys.exit(1) + sys.exit(cli()) PY diff --git a/scripts/install-preflight.sh b/scripts/install-preflight.sh index f55f740..7a47a8f 100644 --- a/scripts/install-preflight.sh +++ b/scripts/install-preflight.sh @@ -1,15 +1,20 @@ #!/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 +mode=binary if [ "$#" -ne 0 ]; then if [ "$#" -eq 1 ] && [ "$1" = --help ]; then - printf '%s\n' 'Usage: sh scripts/install-preflight.sh' \ - 'Checks the proposed Linux ARM64 installation prerequisites; makes no changes.' + printf '%s\n' 'Usage: sh scripts/install-preflight.sh [--source]' \ + 'Read-only Linux ARM64 binary/source dependency check; never installs anything.' exit 0 fi - printf '%s\n' 'No arguments are supported (except --help).' >&2 - exit 2 + if [ "$#" -eq 1 ] && [ "$1" = --source ]; then + mode=source + else + printf '%s\n' 'Only --source or --help is supported.' >&2 + exit 2 + fi fi errors=0 @@ -32,8 +37,9 @@ case "$libc" in errors=1 ;; esac -# Expected for a future curl/tar release bootstrap, not for building from source. -for dep in curl tar sha256sum mktemp mkdir mv; do +# curl is needed for an explicitly approved GitHub download; local archives 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 printf 'Required tool: %s found\n' "$dep" else @@ -55,25 +61,48 @@ fi if command -v python3 >/dev/null 2>&1; then 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 'Python 3.'*) minor=${version#Python 3.} minor=${minor%%.*} 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 - 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 ;; 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 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 if [ "$errors" -ne 0 ]; then printf '%s\n' 'Preflight failed. No changes were made.' >&2 exit 1 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" diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 18876ae..ac3c990 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -1,5 +1,9 @@ """FrameYap installer implementation. Embedded verbatim in install.sh for piped installs.""" import argparse +import contextlib +from datetime import datetime +import io +import importlib.util import fcntl import hashlib import json @@ -7,24 +11,28 @@ import os from pathlib import Path import platform import re +import shlex import shutil import subprocess import sys import tarfile import tempfile from urllib.parse import quote +import urllib.request KEY = "local.frameyap.overlay" MARKER = "# FrameYap managed launcher v1\n" ARCHIVE_LIMIT = 12 * 1024**3 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") CONFIG_DEFAULTS = { "font": "", "input_priority": "normal", "advanced_debug": False, "auto_insert": False, + "close_mic_when_idle": False, + "backend": "redux", "lock_layout": False, "clock_24h": False, "date_format": "mdy", @@ -41,6 +49,7 @@ CONFIG_DEFAULTS = { } 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") +BACKEND_RE = re.compile(r"[a-z][a-z0-9_-]{0,47}\Z", re.ASCII) def fail(message): @@ -92,6 +101,10 @@ def normalized_config(data): fixed["advanced_debug"] = debug if type(debug) is bool else False automatic = data.get("auto_insert", 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) fixed["lock_layout"] = layout if type(layout) is bool else False clock = data.get("clock_24h", False) @@ -193,8 +206,12 @@ def check_digest(value): def check_version(value): - if not VERSION_RE.fullmatch(value) or value in (".", ".."): - fail("invalid release version/tag") + if not VERSION_RE.fullmatch(value): + 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 @@ -235,7 +252,7 @@ def verify_members(archive): total += member.size if total > ARCHIVE_LIMIT: 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 (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())): fail(f"unexpected archive path: {name}") @@ -273,9 +290,11 @@ def validate_payload(root, version, without_model=False, installed=False): fail("missing declared model") if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists(): 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": 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(): fail(f"missing payload file: {name}") for name in ("lib", "fonts"): @@ -437,6 +456,194 @@ def installed_choice(path): 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): version = check_version(args.version) versions = root / "versions" @@ -446,17 +653,20 @@ def do_install(args, root, launcher): # Refuse foreign wrappers/launcher directories before installing a new version. owned_dir(launcher.parent) 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: + 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: 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 sidecar = Path(td) / (name + ".sha256") 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.") -def main(argv=None): - parser = argparse.ArgumentParser(prog="install.sh", description="User-local FrameYap release installer (no SteamVR actions)") - 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") - mode.add_argument("--uninstall", action="store_true") +class UsageError(ValueError): + pass + + +class InstallerParser(argparse.ArgumentParser): + 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("--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("--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("--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") args = parser.parse_args(argv) if args.repo != "baketnk/frame-yap": 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): 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: parser.error("--sha256 only applies to --archive") - if not (args.rollback or args.uninstall or args.archive) and not args.version: - parser.error("--version TAG is required; no moving/latest release") - if args.uninstall and not args.unregistered: - parser.error("uninstall requires --unregistered after explicit OpenVR unregister") - if args.unregistered and not args.uninstall: - parser.error("--unregistered only applies to --uninstall") + if not (args.rollback or args.uninstall or args.install_model) and not args.version: + parser.error("--version VERSION is required; no moving/latest release") + if args.uninstall != args.unregistered: + parser.error("--uninstall requires --unregistered after explicit OpenVR unregister") + if args.model_dir and not args.install_model: + 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: - 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() + 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() root = data / "frameyap" launcher = Path.home() / ".local/bin/frameyap" 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(): fail("refusing symlink lock") with lock.open("a+b") as fd: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: - fail("FrameYap installer or application is running (.lock held); try again later") - if args.uninstall: - uninstall(root, launcher) - elif args.rollback: - owned_dir(root / "versions") - 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}") + fail(f"FrameYap installer or application is running ({lock.name} held); try again later") + other = root / ".model.lock" + if not args.install_model and other.is_symlink(): + fail("refusing symlink model lock") + with (contextlib.nullcontext() if args.install_model else other.open("a+b")) as model_fd: + if model_fd is not None: + try: + fcntl.flock(model_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + fail("model provisioning is running (.model.lock held); retry later") + if args.uninstall: + uninstall(root, launcher) + elif args.rollback: + owned_dir(root / "versions") + 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: - 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__": - try: - main() - except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError) as exc: - print(f"frameyap installer: {exc}", file=sys.stderr) - sys.exit(1) + sys.exit(cli()) diff --git a/scripts/package-release.py b/scripts/package-release.py index 4455385..4ada6c2 100644 --- a/scripts/package-release.py +++ b/scripts/package-release.py @@ -4,6 +4,7 @@ This tool does not build/download a runtime, model, native libraries, or licenses. """ import argparse +from datetime import datetime import hashlib import io import json @@ -15,8 +16,8 @@ import tarfile import tempfile ARCH = "linux-aarch64" -ALLOWED = {"bin", "lib", "assets", "python", "runtime", "model", "fonts", "licenses"} -REQUIRED = ("bin/frameyap", "runtime/bin/python3", "python/frameyap/worker.py", +ALLOWED = {"bin", "lib", "assets", "python", "scripts", "runtime", "model", "fonts", "licenses"} +REQUIRED = ("bin/frameyap", "bin/install.sh", "runtime/bin/python3", "python/frameyap/worker.py", "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("--external-runtime", action="store_true", help="omit ASR runtime; user must supply an independently authorized Python environment") 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 (".", ".."): - p.error("invalid version") + if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]{11}", args.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(): p.error("model revision must be nonempty") stage = args.stage.resolve(strict=True) @@ -47,7 +52,7 @@ def main(argv=None): p.error(f"missing file: {name}") if args.external_runtime and (stage / "runtime").exists(): 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): p.error(f"not executable: {name}") for name in ("lib", "fonts", "licenses"): diff --git a/scripts/stage-native-poc.py b/scripts/stage-native-poc.py index 3a8c801..7f00bb0 100644 --- a/scripts/stage-native-poc.py +++ b/scripts/stage-native-poc.py @@ -1,57 +1,6 @@ #!/usr/bin/env python3 -"""Stage a native-only POC 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 +"""Compatibility entry point for scripts/stage-native.py.""" from pathlib import Path -import shutil -import subprocess +import runpy - -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() +runpy.run_path(str(Path(__file__).with_name('stage-native.py')), run_name='__main__') diff --git a/scripts/stage-native.py b/scripts/stage-native.py new file mode 100644 index 0000000..be11388 --- /dev/null +++ b/scripts/stage-native.py @@ -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() diff --git a/tests/install-preflight.sh b/tests/install-preflight.sh index 143142d..98f1d91 100644 --- a/tests/install-preflight.sh +++ b/tests/install-preflight.sh @@ -33,8 +33,20 @@ check() { check 0 'Git: missing (not required' /usr/bin/env check 0 'uv: missing (not required' /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 'require glibc' /usr/bin/env MOCK_LIBC=musl -/bin/rm "$tmp/bin/curl" -check 1 'curl MISSING' /usr/bin/env +/bin/rm "$tmp/bin/python3" +check 1 'python3 MISSING' /usr/bin/env diff --git a/tests/test_installer.py b/tests/test_installer.py index a74d792..ce2f4b9 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -13,6 +13,10 @@ import tarfile import tempfile import unittest from unittest.mock import patch +import shutil +import pty +import select +from types import SimpleNamespace REPO = Path(__file__).resolve().parents[1] 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()) for name in ("bin/frameyap", "runtime/bin/python3", "runtime/bin/helper", "lib/libtest.so"): (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), "XDG_CONFIG_HOME": str(self.home / ".config")}) self.env.start() @@ -64,10 +72,10 @@ class InstallTests(unittest.TestCase): (REPO / "scripts/install_payload.py").read_text()) def test_install_idempotence_upgrade_rollback_uninstall(self): - a1, h1 = self.package("v1") - self.install("v1", a1, h1) + a1, h1 = self.package("0.1.202609241530") + self.install("0.1.202609241530", a1, h1) 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" self.assertIn("--run", launcher.read_text()) 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.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())["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.assertEqual(list(config.parent.glob("config.json.backup-*")), []) self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text()) - self.assertTrue(os.access(root / "versions/v1/runtime/bin/helper", os.X_OK)) - self.assertTrue(os.access(root / "versions/v1/lib/libtest.so", os.X_OK)) + self.assertTrue(os.access(root / "versions/0.1.202609241530/runtime/bin/helper", 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()) self.assertEqual(manifest["applications"][0]["app_key"], "local.frameyap.overlay") 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("Terminal=false", desktop.read_text()) (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") - a2, h2 = self.package("v2") - self.install("v2", a2, h2) - self.assertEqual(os.readlink(root / "current"), "versions/v2") - self.assertEqual(os.readlink(root / "previous"), "versions/v1") + a2, h2 = self.package("0.1.202609241531") + self.install("0.1.202609241531", a2, h2) + self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241531") + self.assertEqual(os.readlink(root / "previous"), "versions/0.1.202609241530") (root / "frameyap.vrmanifest").write_text("foreign") with self.assertRaisesRegex(ValueError, "foreign file"): 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() with contextlib.redirect_stdout(io.StringIO()): installer.main(["--rollback"]) - self.assertEqual(os.readlink(root / "current"), "versions/v1") - self.assertEqual(os.readlink(root / "previous"), "versions/v2") - with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530") + self.assertEqual(os.readlink(root / "previous"), "versions/0.1.202609241531") + with self.assertRaises(installer.UsageError): installer.main(["--uninstall"]) with contextlib.redirect_stdout(io.StringIO()): installer.main(["--uninstall", "--unregistered"]) self.assertEqual((root / "config-untouched").read_text(), "keep") - self.assertTrue((root / "saved-models/v1/weights.bin").exists()) - self.assertTrue((root / "saved-models/v2/weights.bin").exists()) + self.assertTrue((root / "saved-models/0.1.202609241530/weights.bin").exists()) + self.assertTrue((root / "saved-models/0.1.202609241531/weights.bin").exists()) self.assertFalse(launcher.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): - archive, digest = self.package("v1") + archive, digest = self.package("0.1.202609241530") config = self.home / ".config/frameyap/config.json" config.parent.mkdir(parents=True) original = b'{"font":"/system/face.ttf","theme":{"ink":"#F1f2F3"},"buttons":{"ptt":"/user/hand/left/input/y"}}\n' config.write_bytes(original) - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) fixed = json.loads(config.read_text()) self.assertEqual(fixed["font"], "/system/face.ttf") self.assertEqual(fixed["theme"]["ink"], "#F1f2F3") @@ -147,6 +180,8 @@ class InstallTests(unittest.TestCase): fixed["input_priority"] = "experimental" fixed["advanced_debug"] = True fixed["auto_insert"] = True + fixed["close_mic_when_idle"] = True + fixed["backend"] = "custom_v2-1" fixed["lock_layout"] = True fixed["clock_24h"] = True fixed["date_format"] = "iso" @@ -155,10 +190,12 @@ class InstallTests(unittest.TestCase): fixed["wrist"]["y"] = 0.2 compact = json.dumps(fixed, separators=(",", ":")).encode() config.write_bytes(compact) - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) self.assertEqual(config.read_bytes(), compact) 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())["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())["clock_24h"], True) self.assertEqual(json.loads(config.read_text())["date_format"], "iso") @@ -166,12 +203,12 @@ class InstallTests(unittest.TestCase): fixed["advanced_debug"] = False compact_off = json.dumps(fixed, separators=(",", ":")).encode() 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(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}' config.write_bytes(original) - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) fixed = json.loads(config.read_text()) self.assertEqual(fixed["font"], "/system/face.ttf") self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"]) @@ -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])) original = b'{"font":"one","font":"two"' 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.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) config.write_text(json.dumps({"font": "/" + "x" * 3800})) - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) self.assertEqual(json.loads(config.read_text())["font"], "") self.assertTrue(all(len(p.read_bytes()) > 0 for p in config.parent.glob("config.json.backup-*"))) config.write_bytes(b"x" * 65537) with self.assertRaisesRegex(ValueError, "too large"): - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) self.assertEqual(config.stat().st_size, 65537) config.unlink() config.symlink_to(self.stage / "model/weights.bin") with self.assertRaisesRegex(ValueError, "foreign config path"): - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) self.assertTrue(config.is_symlink()) 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.parent.mkdir(parents=True) for invalid in ("true", 1, None, [], {}): original = json.dumps({"advanced_debug": invalid, "font": "/custom/font.ttf"}).encode() 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.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-*")]) 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.parent.mkdir(parents=True) for invalid in ("true", 1, None, [], {}): original = json.dumps({"auto_insert": invalid, "font": "/custom/font.ttf"}).encode() 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.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): - archive, digest = self.package("v1") + 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({"lock_layout": invalid, "font": "/custom/font.ttf"}).encode() config.write_bytes(original) - self.install("v1", archive, digest) + self.install("0.1.202609241530", archive, digest) fixed = json.loads(config.read_text()) self.assertIs(fixed["lock_layout"], False) self.assertEqual(fixed["font"], "/custom/font.ttf") 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): - a, h = self.package("v1") - self.install("v1", a, h) + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) root = self.data / "frameyap" with self.assertRaisesRegex(ValueError, "SHA-256 mismatch"): - self.install("v1", a, "0" * 64) + self.install("0.1.202609241530", a, "0" * 64) a.unlink() (self.output / (a.name + ".sha256")).unlink() (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"): - self.install("v1", a, h2) - self.assertEqual(os.readlink(root / "current"), "versions/v1") + self.install("0.1.202609241530", a, h2) + self.assertEqual(os.readlink(root / "current"), "versions/0.1.202609241530") 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.parent.mkdir(parents=True) with lock.open("a+b") as fd: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) with self.assertRaisesRegex(ValueError, "running"): - self.install("v1", a, h) - self.install("v1", a, h, "--without-model") + self.install("0.1.202609241530", a, h) + self.install("0.1.202609241530", a, h, "--without-model") 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.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}) - self.install("v1", a, h, "--without-model") - self.assertFalse((root / "versions/v1/model").exists()) + self.install("0.1.202609241530", a, h, "--without-model") + self.assertFalse((root / "versions/0.1.202609241530/model").exists()) with self.assertRaisesRegex(ValueError, "different --without-model choice"): - self.install("v1", a, h) - self.assertFalse((root / "versions/v1/model").exists()) + self.install("0.1.202609241530", a, h) + self.assertFalse((root / "versions/0.1.202609241530/model").exists()) (root / "frameyap.vrmanifest").unlink() launcher = self.home / ".local/bin/frameyap" launcher.unlink() - self.install("v1", a, h, "--without-model") + self.install("0.1.202609241530", a, h, "--without-model") self.assertTrue(launcher.exists()) self.assertTrue((root / "frameyap.vrmanifest").exists()) (root / "frameyap.vrmanifest").write_text("foreign") 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() - 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.stage / "bin/frameyap").write_text("new") - a2, h2 = self.package("v2") + a2, h2 = self.package("0.1.202609241531") with self.assertRaisesRegex(ValueError, "foreign file"): - self.install("v2", a2, h2) - self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/v1") - self.assertFalse((self.data / "frameyap/versions/v2").exists()) + self.install("0.1.202609241531", a2, h2) + self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241530") + self.assertFalse((self.data / "frameyap/versions/0.1.202609241531").exists()) 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.parent.mkdir(parents=True) desktop.write_text("foreign shortcut\n") 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.assertFalse((self.data / "frameyap/current").exists()) @@ -309,8 +363,8 @@ class InstallTests(unittest.TestCase): self.assertIn('Exec="/home/steam%%user/.local/bin/frameyap"', entry) def test_traversal_and_link_archives_rejected(self): - a, h = self.package("v1") - self.install("v1", a, h) + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) for badname, kind in (("../outside", "file"), ("bin/escape", "symlink"), ("/absolute", "file"), ("bin/escape", "hardlink")): with self.subTest(badname=badname, kind=kind): @@ -326,8 +380,8 @@ class InstallTests(unittest.TestCase): tar.addfile(info, io.BytesIO(b"x")) sha = hashlib.sha256(bad.read_bytes()).hexdigest() with self.assertRaisesRegex(ValueError, "unsafe archive|forbidden"): - self.install("v2", bad, sha) - self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/v1") + self.install("0.1.202609241531", bad, sha) + self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241530") self.assertFalse((self.base / "outside").exists()) 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)) sha = hashlib.sha256(bad.read_bytes()).hexdigest() 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): - a, h = self.package("v1") - self.install("v1", a, h) + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) root = self.data / "frameyap" - extra = root / "versions/v1/user.txt" + extra = root / "versions/0.1.202609241530/user.txt" extra.write_text("preserve") with self.assertRaisesRegex(ValueError, "untracked files"): with contextlib.redirect_stdout(io.StringIO()): installer.main(["--uninstall", "--unregistered"]) 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): - a, h = self.package("v1") - self.install("v1", a, h) - a2, h2 = self.package("v2") + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) + a2, h2 = self.package("0.1.202609241531") with self.assertRaisesRegex(ValueError, "metadata version/architecture/schema mismatch"): - self.install("v3", a2, h2) - self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/v1") + self.install("0.1.202609241532", a2, h2) + self.assertEqual(os.readlink(self.data / "frameyap/current"), "versions/0.1.202609241530") def test_launcher_defaults_run_but_forwards_registration(self): path = self.stage / "bin/frameyap" path.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\nprintf "ENV:%s\\n" "${PYTHONDONTWRITEBYTECODE:-}"\n') path.chmod(0o755) - a, h = self.package("v1") - self.install("v1", a, h) + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) launcher = self.home / ".local/bin/frameyap" 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"]) @@ -377,33 +431,33 @@ class InstallTests(unittest.TestCase): self.assertIn("--assets", run.stdout) self.assertIn("--socket\nfixture-socket\n", 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}) with self.assertRaisesRegex(ValueError, "different --without-model choice"): - self.install("v1", a, h, "--without-model") - self.assertTrue((self.data / "frameyap/versions/v1/model/weights.bin").exists()) + self.install("0.1.202609241530", a, h, "--without-model") + self.assertTrue((self.data / "frameyap/versions/0.1.202609241530/model/weights.bin").exists()) def test_no_model_reinstall_preserves_provisioned_model_and_uninstall(self): - a, h = self.package("v1") - self.install("v1", a, h, "--without-model") + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h, "--without-model") root = self.data / "frameyap" - model = root / "versions/v1/model" + model = root / "versions/0.1.202609241530/model" model.mkdir() (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()): 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): - a, h = self.package("v1") - self.install("v1", a, h) - (self.data / "frameyap/versions/v1/untracked").write_text("keep") - a2, h2 = self.package("v2") + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) + (self.data / "frameyap/versions/0.1.202609241530/untracked").write_text("keep") + a2, h2 = self.package("0.1.202609241531") 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. - self.install("v2", a2, h2) + self.install("0.1.202609241531", a2, h2) with self.assertRaisesRegex(ValueError, "untracked files"): installer.main(["--uninstall", "--unregistered"]) @@ -413,8 +467,8 @@ class InstallTests(unittest.TestCase): native = self.stage / "bin/frameyap" native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') native.chmod(0o755) - a, h = self.package("external", "--external-runtime") - self.install("external", a, h) + a, h = self.package("0.1.202609241533", "--external-runtime") + self.install("0.1.202609241533", a, h) root = self.data / "frameyap/current" self.assertEqual(json.loads((root / "release.json").read_text())["runtime"], "external-authorized-python") self.assertFalse((root / "runtime").exists()) @@ -432,14 +486,14 @@ class InstallTests(unittest.TestCase): native = self.stage / "bin/frameyap" native.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') native.chmod(0o755) - a, h = self.package("external", "--external-runtime") - self.install("external", a, h) + a, h = self.package("0.1.202609241533", "--external-runtime") + self.install("0.1.202609241533", a, h) root = self.data / "frameyap" launcher = self.home / ".local/bin/frameyap" # A pre-config release launcher may be upgraded only when its exact # bytes match the managed legacy template, not just its marker. 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)) config = self.home / ".config/frameyap/paths.conf" 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. launcher.write_bytes(installer.desired_launcher(root, legacy=True) + b"# changed\n") 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): (self.stage / "lib/link.so").symlink_to("libtest.so") 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) self.assertNotEqual(result.returncode, 0) self.assertIn("links and special files forbidden", result.stderr) - def test_utc_timestamp_release_tag_roundtrip(self): - version = "2026-09-24T162712Z-g417f81c-dirty" - archive, digest = self.package(version) - self.assertEqual(archive.name, f"frameyap-{version}-linux-aarch64.tar.gz") - self.install(version, archive, digest) + def test_numeric_utc_release_version_roundtrip(self): + for version in ("0.1.202609241627", "2.13.202609241628", "12.0.202609241629"): + with self.subTest(version=version): + archive, digest = self.package(version) + 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" - self.assertEqual(os.readlink(root / "current"), f"versions/{version}") - self.assertEqual(json.loads((root / "current/release.json").read_text())["version"], version) + response = io.StringIO() + 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__":